diff --git a/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts new file mode 100644 index 0000000000..c4cafbc1c5 --- /dev/null +++ b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts @@ -0,0 +1,210 @@ +/** + * Integration test: Chunk preview formatting pipeline + * + * Tests the formatPreviewChunks utility across all chunking modes + * (text, parentChild, QA) with real data structures. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/config', () => ({ + RAG_PIPELINE_PREVIEW_CHUNK_NUM: 3, +})) + +vi.mock('@/models/datasets', () => ({ + ChunkingMode: { + text: 'text', + parentChild: 'parent-child', + qa: 'qa', + }, +})) + +const { formatPreviewChunks } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils', +) + +describe('Chunk Preview Formatting', () => { + describe('general text chunks', () => { + it('should format text chunks correctly', () => { + const outputs = { + chunk_structure: 'text', + preview: [ + { content: 'Chunk 1 content', summary: 'Summary 1' }, + { content: 'Chunk 2 content' }, + ], + } + + const result = formatPreviewChunks(outputs) + + expect(Array.isArray(result)).toBe(true) + const chunks = result as Array<{ content: string, summary?: string }> + expect(chunks).toHaveLength(2) + expect(chunks[0].content).toBe('Chunk 1 content') + expect(chunks[0].summary).toBe('Summary 1') + expect(chunks[1].content).toBe('Chunk 2 content') + }) + + it('should limit chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => { + const outputs = { + chunk_structure: 'text', + preview: Array.from({ length: 10 }, (_, i) => ({ + content: `Chunk ${i + 1}`, + })), + } + + const result = formatPreviewChunks(outputs) + const chunks = result as Array<{ content: string }> + + expect(chunks).toHaveLength(3) // Mocked limit + }) + }) + + describe('parent-child chunks — paragraph mode', () => { + it('should format paragraph parent-child chunks', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'paragraph', + preview: [ + { + content: 'Parent paragraph', + child_chunks: ['Child 1', 'Child 2'], + summary: 'Parent summary', + }, + ], + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: Array<{ + parent_content: string + parent_summary?: string + child_contents: string[] + parent_mode: string + }> + parent_mode: string + } + + expect(result.parent_mode).toBe('paragraph') + expect(result.parent_child_chunks).toHaveLength(1) + expect(result.parent_child_chunks[0].parent_content).toBe('Parent paragraph') + expect(result.parent_child_chunks[0].parent_summary).toBe('Parent summary') + expect(result.parent_child_chunks[0].child_contents).toEqual(['Child 1', 'Child 2']) + }) + + it('should limit parent chunks in paragraph mode', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'paragraph', + preview: Array.from({ length: 10 }, (_, i) => ({ + content: `Parent ${i + 1}`, + child_chunks: [`Child of ${i + 1}`], + })), + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: unknown[] + } + + expect(result.parent_child_chunks).toHaveLength(3) // Mocked limit + }) + }) + + describe('parent-child chunks — full-doc mode', () => { + it('should format full-doc parent-child chunks', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'full-doc', + preview: [ + { + content: 'Full document content', + child_chunks: ['Section 1', 'Section 2', 'Section 3'], + }, + ], + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: Array<{ + parent_content: string + child_contents: string[] + parent_mode: string + }> + } + + expect(result.parent_child_chunks).toHaveLength(1) + expect(result.parent_child_chunks[0].parent_content).toBe('Full document content') + expect(result.parent_child_chunks[0].parent_mode).toBe('full-doc') + }) + + it('should limit child chunks in full-doc mode', () => { + const outputs = { + chunk_structure: 'parent-child', + parent_mode: 'full-doc', + preview: [ + { + content: 'Document', + child_chunks: Array.from({ length: 20 }, (_, i) => `Section ${i + 1}`), + }, + ], + } + + const result = formatPreviewChunks(outputs) as { + parent_child_chunks: Array<{ child_contents: string[] }> + } + + expect(result.parent_child_chunks[0].child_contents).toHaveLength(3) // Mocked limit + }) + }) + + describe('QA chunks', () => { + it('should format QA chunks correctly', () => { + const outputs = { + chunk_structure: 'qa', + qa_preview: [ + { question: 'What is AI?', answer: 'Artificial Intelligence is...' }, + { question: 'What is ML?', answer: 'Machine Learning is...' }, + ], + } + + const result = formatPreviewChunks(outputs) as { + qa_chunks: Array<{ question: string, answer: string }> + } + + expect(result.qa_chunks).toHaveLength(2) + expect(result.qa_chunks[0].question).toBe('What is AI?') + expect(result.qa_chunks[0].answer).toBe('Artificial Intelligence is...') + }) + + it('should limit QA chunks', () => { + const outputs = { + chunk_structure: 'qa', + qa_preview: Array.from({ length: 10 }, (_, i) => ({ + question: `Q${i + 1}`, + answer: `A${i + 1}`, + })), + } + + const result = formatPreviewChunks(outputs) as { + qa_chunks: unknown[] + } + + expect(result.qa_chunks).toHaveLength(3) // Mocked limit + }) + }) + + describe('edge cases', () => { + it('should return undefined for null outputs', () => { + expect(formatPreviewChunks(null)).toBeUndefined() + }) + + it('should return undefined for undefined outputs', () => { + expect(formatPreviewChunks(undefined)).toBeUndefined() + }) + + it('should return undefined for unknown chunk_structure', () => { + const outputs = { + chunk_structure: 'unknown-type', + preview: [], + } + + expect(formatPreviewChunks(outputs)).toBeUndefined() + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts new file mode 100644 index 0000000000..578552840d --- /dev/null +++ b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts @@ -0,0 +1,179 @@ +/** + * Integration test: DSL export/import flow + * + * Validates DSL export logic (sync draft → check secrets → download) + * and DSL import modal state management. + */ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined) +const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' }) +const mockNotify = vi.fn() +const mockEventEmitter = { emit: vi.fn() } +const mockDownloadBlob = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +vi.mock('@/app/components/workflow/constants', () => ({ + DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + pipelineId: 'pipeline-abc', + knowledgeName: 'My Pipeline', + }), + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: mockEventEmitter, + }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useExportPipelineDSL: () => ({ + mutateAsync: mockExportPipelineConfig, + }), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: vi.fn(), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: mockDoSyncWorkflowDraft, + }), +})) + +describe('DSL Export/Import Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Export Flow', () => { + it('should sync draft then export then download', async () => { + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL() + }) + + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockExportPipelineConfig).toHaveBeenCalledWith({ + pipelineId: 'pipeline-abc', + include: false, + }) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ + fileName: 'My Pipeline.pipeline', + })) + }) + + it('should export with include flag when specified', async () => { + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL(true) + }) + + expect(mockExportPipelineConfig).toHaveBeenCalledWith({ + pipelineId: 'pipeline-abc', + include: true, + }) + }) + + it('should notify on export error', async () => { + mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed')) + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + describe('Export Check Flow', () => { + it('should export directly when no secret environment variables', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({ + environment_variables: [ + { value_type: 'string', key: 'API_URL', value: 'https://api.example.com' }, + ], + } as unknown as Awaited>) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + // Should proceed to export directly (no secret vars) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + }) + + it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({ + environment_variables: [ + { value_type: 'secret', key: 'API_KEY', value: '***' }, + ], + } as unknown as Awaited>) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'DSL_EXPORT_CHECK', + payload: expect.objectContaining({ + data: expect.arrayContaining([ + expect.objectContaining({ value_type: 'secret' }), + ]), + }), + })) + }) + + it('should notify on export check error', async () => { + const { fetchWorkflowDraft } = await import('@/service/workflow') + vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed')) + + const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL') + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts new file mode 100644 index 0000000000..233c9a288a --- /dev/null +++ b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts @@ -0,0 +1,278 @@ +/** + * Integration test: Input field CRUD complete flow + * + * Validates the full lifecycle of input fields: + * creation, editing, renaming, removal, and data conversion round-trip. + */ +import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types' +import type { InputVar } from '@/models/pipeline' +import { describe, expect, it, vi } from 'vitest' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { PipelineInputVarType } from '@/models/pipeline' +import { TransferMethod } from '@/types/app' + +vi.mock('@/config', () => ({ + VAR_ITEM_TEMPLATE_IN_PIPELINE: { + type: 'text-input', + label: '', + variable: '', + max_length: 48, + default_value: undefined, + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, +})) + +describe('Input Field CRUD Flow', () => { + describe('Create → Edit → Convert Round-trip', () => { + it('should create a text field and roundtrip through form data', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + // Create new field from template (no data passed) + const newFormData = convertToInputFieldFormData() + expect(newFormData.type).toBe('text-input') + expect(newFormData.variable).toBe('') + expect(newFormData.label).toBe('') + expect(newFormData.required).toBe(true) + + // Simulate user editing form data + const editedFormData: FormData = { + ...newFormData, + variable: 'user_name', + label: 'User Name', + maxLength: 100, + default: 'John', + tooltips: 'Enter your name', + placeholder: 'Type here...', + allowedTypesAndExtensions: {}, + } + + // Convert back to InputVar + const inputVar = convertFormDataToINputField(editedFormData) + + expect(inputVar.variable).toBe('user_name') + expect(inputVar.label).toBe('User Name') + expect(inputVar.max_length).toBe(100) + expect(inputVar.default_value).toBe('John') + expect(inputVar.tooltips).toBe('Enter your name') + expect(inputVar.placeholder).toBe('Type here...') + expect(inputVar.required).toBe(true) + }) + + it('should handle file field with upload settings', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const fileInputVar: InputVar = { + type: PipelineInputVarType.singleFile, + label: 'Upload Document', + variable: 'doc_file', + max_length: 1, + default_value: undefined, + required: true, + tooltips: 'Upload a PDF', + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['.pdf', '.docx'], + } + + // Convert to form data + const formData = convertToInputFieldFormData(fileInputVar) + expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(formData.allowedTypesAndExtensions).toEqual({ + allowedFileTypes: [SupportUploadFileTypes.document], + allowedFileExtensions: ['.pdf', '.docx'], + }) + + // Round-trip back + const restored = convertFormDataToINputField(formData) + expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url]) + expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document]) + expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx']) + }) + + it('should handle select field with options', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const selectVar: InputVar = { + type: PipelineInputVarType.select, + label: 'Priority', + variable: 'priority', + max_length: 0, + default_value: 'medium', + required: false, + tooltips: 'Select priority level', + options: ['low', 'medium', 'high'], + placeholder: 'Choose...', + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(selectVar) + expect(formData.options).toEqual(['low', 'medium', 'high']) + expect(formData.default).toBe('medium') + + const restored = convertFormDataToINputField(formData) + expect(restored.options).toEqual(['low', 'medium', 'high']) + expect(restored.default_value).toBe('medium') + }) + + it('should handle number field with unit', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const numberVar: InputVar = { + type: PipelineInputVarType.number, + label: 'Max Tokens', + variable: 'max_tokens', + max_length: 0, + default_value: '1024', + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: 'tokens', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(numberVar) + expect(formData.unit).toBe('tokens') + expect(formData.default).toBe('1024') + + const restored = convertFormDataToINputField(formData) + expect(restored.unit).toBe('tokens') + expect(restored.default_value).toBe('1024') + }) + }) + + describe('Omit optional fields', () => { + it('should not include tooltips when undefined', async () => { + const { convertToInputFieldFormData } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const inputVar: InputVar = { + type: PipelineInputVarType.textInput, + label: 'Test', + variable: 'test', + max_length: 48, + default_value: undefined, + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(inputVar) + + // Optional fields should not be present + expect('tooltips' in formData).toBe(false) + expect('placeholder' in formData).toBe(false) + expect('unit' in formData).toBe(false) + expect('default' in formData).toBe(false) + }) + + it('should include optional fields when explicitly set to empty string', async () => { + const { convertToInputFieldFormData } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const inputVar: InputVar = { + type: PipelineInputVarType.textInput, + label: 'Test', + variable: 'test', + max_length: 48, + default_value: '', + required: true, + tooltips: '', + options: [], + placeholder: '', + unit: '', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.default).toBe('') + expect(formData.tooltips).toBe('') + expect(formData.placeholder).toBe('') + expect(formData.unit).toBe('') + }) + }) + + describe('Multiple fields workflow', () => { + it('should process multiple fields independently', async () => { + const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', + ) + + const fields: InputVar[] = [ + { + type: PipelineInputVarType.textInput, + label: 'Name', + variable: 'name', + max_length: 48, + default_value: 'Alice', + required: true, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: undefined, + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, + { + type: PipelineInputVarType.number, + label: 'Count', + variable: 'count', + max_length: 0, + default_value: '10', + required: false, + tooltips: undefined, + options: [], + placeholder: undefined, + unit: 'items', + allowed_file_upload_methods: undefined, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + }, + ] + + const formDataList = fields.map(f => convertToInputFieldFormData(f)) + const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd)) + + expect(restoredFields).toHaveLength(2) + expect(restoredFields[0].variable).toBe('name') + expect(restoredFields[0].default_value).toBe('Alice') + expect(restoredFields[1].variable).toBe('count') + expect(restoredFields[1].default_value).toBe('10') + expect(restoredFields[1].unit).toBe('items') + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts new file mode 100644 index 0000000000..0fc4699aa8 --- /dev/null +++ b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts @@ -0,0 +1,199 @@ +/** + * Integration test: Input field editor data conversion flow + * + * Tests the full pipeline: InputVar -> FormData -> InputVar roundtrip + * and schema validation for various input types. + */ +import type { InputVar } from '@/models/pipeline' +import { describe, expect, it, vi } from 'vitest' +import { PipelineInputVarType } from '@/models/pipeline' + +// Mock the config module for VAR_ITEM_TEMPLATE_IN_PIPELINE +vi.mock('@/config', () => ({ + VAR_ITEM_TEMPLATE_IN_PIPELINE: { + type: 'text-input', + label: '', + variable: '', + max_length: 48, + required: false, + options: [], + allowed_file_upload_methods: [], + allowed_file_types: [], + allowed_file_extensions: [], + }, + MAX_VAR_KEY_LENGTH: 30, + RAG_PIPELINE_PREVIEW_CHUNK_NUM: 10, +})) + +// Import real functions (not mocked) +const { convertToInputFieldFormData, convertFormDataToINputField } = await import( + '@/app/components/rag-pipeline/components/panel/input-field/editor/utils', +) + +describe('Input Field Editor Data Flow', () => { + describe('convertToInputFieldFormData', () => { + it('should convert a text input InputVar to FormData', () => { + const inputVar: InputVar = { + type: 'text-input', + label: 'Name', + variable: 'user_name', + max_length: 100, + required: true, + default_value: 'John', + tooltips: 'Enter your name', + placeholder: 'Type here...', + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.type).toBe('text-input') + expect(formData.label).toBe('Name') + expect(formData.variable).toBe('user_name') + expect(formData.maxLength).toBe(100) + expect(formData.required).toBe(true) + expect(formData.default).toBe('John') + expect(formData.tooltips).toBe('Enter your name') + expect(formData.placeholder).toBe('Type here...') + }) + + it('should handle file input with upload settings', () => { + const inputVar: InputVar = { + type: 'file', + label: 'Document', + variable: 'doc', + required: false, + allowed_file_upload_methods: ['local_file', 'remote_url'], + allowed_file_types: ['document', 'image'], + allowed_file_extensions: ['.pdf', '.jpg'], + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.allowedFileUploadMethods).toEqual(['local_file', 'remote_url']) + expect(formData.allowedTypesAndExtensions).toEqual({ + allowedFileTypes: ['document', 'image'], + allowedFileExtensions: ['.pdf', '.jpg'], + }) + }) + + it('should use template defaults when no data provided', () => { + const formData = convertToInputFieldFormData(undefined) + + expect(formData.type).toBe('text-input') + expect(formData.maxLength).toBe(48) + expect(formData.required).toBe(false) + }) + + it('should omit undefined/null optional fields', () => { + const inputVar: InputVar = { + type: 'text-input', + label: 'Simple', + variable: 'simple_var', + max_length: 50, + required: false, + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(inputVar) + + expect(formData.default).toBeUndefined() + expect(formData.tooltips).toBeUndefined() + expect(formData.placeholder).toBeUndefined() + expect(formData.unit).toBeUndefined() + }) + }) + + describe('convertFormDataToINputField', () => { + it('should convert FormData back to InputVar', () => { + const formData = { + type: PipelineInputVarType.textInput, + label: 'Name', + variable: 'user_name', + maxLength: 100, + required: true, + default: 'John', + tooltips: 'Enter your name', + options: [], + placeholder: 'Type here...', + allowedTypesAndExtensions: { + allowedFileTypes: undefined, + allowedFileExtensions: undefined, + }, + } + + const inputVar = convertFormDataToINputField(formData) + + expect(inputVar.type).toBe('text-input') + expect(inputVar.label).toBe('Name') + expect(inputVar.variable).toBe('user_name') + expect(inputVar.max_length).toBe(100) + expect(inputVar.required).toBe(true) + expect(inputVar.default_value).toBe('John') + expect(inputVar.tooltips).toBe('Enter your name') + }) + }) + + describe('roundtrip conversion', () => { + it('should preserve text input data through roundtrip', () => { + const original: InputVar = { + type: 'text-input', + label: 'Question', + variable: 'question', + max_length: 200, + required: true, + default_value: 'What is AI?', + tooltips: 'Enter your question', + placeholder: 'Ask something...', + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(original) + const restored = convertFormDataToINputField(formData) + + expect(restored.type).toBe(original.type) + expect(restored.label).toBe(original.label) + expect(restored.variable).toBe(original.variable) + expect(restored.max_length).toBe(original.max_length) + expect(restored.required).toBe(original.required) + expect(restored.default_value).toBe(original.default_value) + expect(restored.tooltips).toBe(original.tooltips) + expect(restored.placeholder).toBe(original.placeholder) + }) + + it('should preserve number input data through roundtrip', () => { + const original = { + type: 'number', + label: 'Temperature', + variable: 'temp', + required: false, + default_value: '0.7', + unit: '°C', + options: [], + } as InputVar + + const formData = convertToInputFieldFormData(original) + const restored = convertFormDataToINputField(formData) + + expect(restored.type).toBe('number') + expect(restored.unit).toBe('°C') + expect(restored.default_value).toBe('0.7') + }) + + it('should preserve select options through roundtrip', () => { + const original: InputVar = { + type: 'select', + label: 'Mode', + variable: 'mode', + required: true, + options: ['fast', 'balanced', 'quality'], + } as InputVar + + const formData = convertToInputFieldFormData(original) + const restored = convertFormDataToINputField(formData) + + expect(restored.options).toEqual(['fast', 'balanced', 'quality']) + }) + }) +}) diff --git a/web/__tests__/rag-pipeline/test-run-flow.test.ts b/web/__tests__/rag-pipeline/test-run-flow.test.ts new file mode 100644 index 0000000000..a2bf557acd --- /dev/null +++ b/web/__tests__/rag-pipeline/test-run-flow.test.ts @@ -0,0 +1,277 @@ +/** + * Integration test: Test run end-to-end flow + * + * Validates the data flow through test-run preparation hooks: + * step navigation, datasource filtering, and data clearing. + */ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { BlockEnum } from '@/app/components/workflow/types' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mutable holder so mock data can reference BlockEnum after imports +const mockNodesHolder = vi.hoisted(() => ({ value: [] as Record[] })) + +vi.mock('reactflow', () => ({ + useNodes: () => mockNodesHolder.value, +})) + +mockNodesHolder.value = [ + { + id: 'ds-1', + data: { + type: BlockEnum.DataSource, + title: 'Local Files', + datasource_type: 'upload_file', + datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} }, + }, + }, + { + id: 'ds-2', + data: { + type: BlockEnum.DataSource, + title: 'Web Crawl', + datasource_type: 'website_crawl', + datasource_configurations: { datasource_label: 'Crawl' }, + }, + }, + { + id: 'kb-1', + data: { + type: BlockEnum.KnowledgeBase, + title: 'Knowledge Base', + }, + }, +] + +// Mock the Zustand store used by the hooks +const mockSetDocumentsData = vi.fn() +const mockSetSearchValue = vi.fn() +const mockSetSelectedPagesId = vi.fn() +const mockSetOnlineDocuments = vi.fn() +const mockSetCurrentDocument = vi.fn() +const mockSetStep = vi.fn() +const mockSetCrawlResult = vi.fn() +const mockSetWebsitePages = vi.fn() +const mockSetPreviewIndex = vi.fn() +const mockSetCurrentWebsite = vi.fn() +const mockSetOnlineDriveFileList = vi.fn() +const mockSetBucket = vi.fn() +const mockSetPrefix = vi.fn() +const mockSetKeywords = vi.fn() +const mockSetSelectedFileIds = vi.fn() + +vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({ + useDataSourceStore: () => ({ + getState: () => ({ + setDocumentsData: mockSetDocumentsData, + setSearchValue: mockSetSearchValue, + setSelectedPagesId: mockSetSelectedPagesId, + setOnlineDocuments: mockSetOnlineDocuments, + setCurrentDocument: mockSetCurrentDocument, + setStep: mockSetStep, + setCrawlResult: mockSetCrawlResult, + setWebsitePages: mockSetWebsitePages, + setPreviewIndex: mockSetPreviewIndex, + setCurrentWebsite: mockSetCurrentWebsite, + setOnlineDriveFileList: mockSetOnlineDriveFileList, + setBucket: mockSetBucket, + setPrefix: mockSetPrefix, + setKeywords: mockSetKeywords, + setSelectedFileIds: mockSetSelectedFileIds, + }), + }), +})) + +vi.mock('@/models/datasets', () => ({ + CrawlStep: { + init: 'init', + }, +})) + +describe('Test Run Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Step Navigation', () => { + it('should start at step 1 and navigate forward', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.currentStep).toBe(1) + + act(() => { + result.current.handleNextStep() + }) + + expect(result.current.currentStep).toBe(2) + }) + + it('should navigate back from step 2 to step 1', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(1) + }) + + it('should provide labeled steps', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + expect(result.current.steps).toHaveLength(2) + expect(result.current.steps[0].value).toBe('dataSource') + expect(result.current.steps[1].value).toBe('documentProcessing') + }) + }) + + describe('Datasource Options', () => { + it('should filter nodes to only DataSource type', async () => { + const { useDatasourceOptions } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useDatasourceOptions()) + + // Should only include DataSource nodes, not KnowledgeBase + expect(result.current).toHaveLength(2) + expect(result.current[0].value).toBe('ds-1') + expect(result.current[1].value).toBe('ds-2') + }) + + it('should include node data in options', async () => { + const { useDatasourceOptions } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useDatasourceOptions()) + + expect(result.current[0].label).toBe('Local Files') + expect(result.current[0].data.type).toBe(BlockEnum.DataSource) + }) + }) + + describe('Data Clearing Flow', () => { + it('should clear online document data', async () => { + const { useOnlineDocument } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useOnlineDocument()) + + act(() => { + result.current.clearOnlineDocumentData() + }) + + expect(mockSetDocumentsData).toHaveBeenCalledWith([]) + expect(mockSetSearchValue).toHaveBeenCalledWith('') + expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set)) + expect(mockSetOnlineDocuments).toHaveBeenCalledWith([]) + expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined) + }) + + it('should clear website crawl data', async () => { + const { useWebsiteCrawl } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useWebsiteCrawl()) + + act(() => { + result.current.clearWebsiteCrawlData() + }) + + expect(mockSetStep).toHaveBeenCalledWith('init') + expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined) + expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined) + expect(mockSetWebsitePages).toHaveBeenCalledWith([]) + expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1) + }) + + it('should clear online drive data', async () => { + const { useOnlineDrive } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useOnlineDrive()) + + act(() => { + result.current.clearOnlineDriveData() + }) + + expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockSetBucket).toHaveBeenCalledWith('') + expect(mockSetPrefix).toHaveBeenCalledWith([]) + expect(mockSetKeywords).toHaveBeenCalledWith('') + expect(mockSetSelectedFileIds).toHaveBeenCalledWith([]) + }) + }) + + describe('Full Flow Simulation', () => { + it('should support complete step navigation cycle', async () => { + const { useTestRunSteps } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result } = renderHook(() => useTestRunSteps()) + + // Start at step 1 + expect(result.current.currentStep).toBe(1) + + // Move to step 2 + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + + // Go back to step 1 + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(1) + + // Move forward again + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + }) + + it('should not regress when clearing all data sources in sequence', async () => { + const { + useOnlineDocument, + useWebsiteCrawl, + useOnlineDrive, + } = await import( + '@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', + ) + const { result: docResult } = renderHook(() => useOnlineDocument()) + const { result: crawlResult } = renderHook(() => useWebsiteCrawl()) + const { result: driveResult } = renderHook(() => useOnlineDrive()) + + // Clear all data sources + act(() => { + docResult.current.clearOnlineDocumentData() + crawlResult.current.clearWebsiteCrawlData() + driveResult.current.clearOnlineDriveData() + }) + + expect(mockSetDocumentsData).toHaveBeenCalledWith([]) + expect(mockSetStep).toHaveBeenCalledWith('init') + expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/index.spec.tsx b/web/app/components/rag-pipeline/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/rag-pipeline/index.spec.tsx rename to web/app/components/rag-pipeline/__tests__/index.spec.tsx index 5adfc828cf..221713defe 100644 --- a/web/app/components/rag-pipeline/index.spec.tsx +++ b/web/app/components/rag-pipeline/__tests__/index.spec.tsx @@ -3,45 +3,36 @@ import { cleanup, render, screen } from '@testing-library/react' import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' -// Import real utility functions (pure functions, no side effects) - -// Import mocked modules for manipulation import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { usePipelineInit } from './hooks' -import RagPipelineWrapper from './index' -import { processNodesWithoutDataSource } from './utils' +import { usePipelineInit } from '../hooks' +import RagPipelineWrapper from '../index' +import { processNodesWithoutDataSource } from '../utils' -// Mock: Context - need to control return values vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: vi.fn(), })) -// Mock: Hook with API calls -vi.mock('./hooks', () => ({ +vi.mock('../hooks', () => ({ usePipelineInit: vi.fn(), })) -// Mock: Store creator -vi.mock('./store', () => ({ +vi.mock('../store', () => ({ createRagPipelineSliceSlice: vi.fn(() => ({})), })) -// Mock: Utility with complex workflow dependencies (generateNewNode, etc.) -vi.mock('./utils', () => ({ +vi.mock('../utils', () => ({ processNodesWithoutDataSource: vi.fn((nodes, viewport) => ({ nodes, viewport, })), })) -// Mock: Complex component with useParams, Toast, API calls -vi.mock('./components/conversion', () => ({ +vi.mock('../components/conversion', () => ({ default: () =>
Conversion Component
, })) -// Mock: Complex component with many hooks and workflow dependencies -vi.mock('./components/rag-pipeline-main', () => ({ - default: ({ nodes, edges, viewport }: any) => ( +vi.mock('../components/rag-pipeline-main', () => ({ + default: ({ nodes, edges, viewport }: { nodes?: unknown[], edges?: unknown[], viewport?: { zoom?: number } }) => (
{nodes?.length ?? 0} {edges?.length ?? 0} @@ -50,35 +41,29 @@ vi.mock('./components/rag-pipeline-main', () => ({ ), })) -// Mock: Complex component with ReactFlow and many providers vi.mock('@/app/components/workflow', () => ({ default: ({ children }: { children: React.ReactNode }) => (
{children}
), })) -// Mock: Context provider vi.mock('@/app/components/workflow/context', () => ({ WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
{children}
), })) -// Type assertions for mocked functions const mockUseDatasetDetailContextWithSelector = vi.mocked(useDatasetDetailContextWithSelector) const mockUsePipelineInit = vi.mocked(usePipelineInit) const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSource) -// Helper to mock selector with actual execution (increases function coverage) -// This executes the real selector function: s => s.dataset?.pipeline_id const mockSelectorWithDataset = (pipelineId: string | null | undefined) => { - mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: any) => any) => { + mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record) => unknown) => { const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null } return selector(mockState) }) } -// Test data factory const createMockWorkflowData = (overrides?: Partial): FetchWorkflowDraftResponse => ({ graph: { nodes: [ @@ -157,7 +142,6 @@ describe('RagPipelineWrapper', () => { describe('RagPipeline', () => { beforeEach(() => { - // Default setup for RagPipeline tests - execute real selector function mockSelectorWithDataset('pipeline-123') }) @@ -167,7 +151,6 @@ describe('RagPipeline', () => { render() - // Real Loading component has role="status" expect(screen.getByRole('status')).toBeInTheDocument() }) @@ -240,8 +223,6 @@ describe('RagPipeline', () => { render() - // initialNodes is a real function - verify nodes are rendered - // The real initialNodes processes nodes and adds position data expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument() }) @@ -251,7 +232,6 @@ describe('RagPipeline', () => { render() - // initialEdges is a real function - verify component renders with edges expect(screen.getByTestId('edges-count').textContent).toBe('1') }) @@ -269,7 +249,6 @@ describe('RagPipeline', () => { render() - // When data is undefined, Loading is shown, processNodesWithoutDataSource is not called expect(mockProcessNodesWithoutDataSource).not.toHaveBeenCalled() }) @@ -279,13 +258,10 @@ describe('RagPipeline', () => { const { rerender } = render() - // Clear mock call count after initial render mockProcessNodesWithoutDataSource.mockClear() - // Rerender with same data reference (no change to mockUsePipelineInit) rerender() - // processNodesWithoutDataSource should not be called again due to useMemo // Note: React strict mode may cause double render, so we check it's not excessive expect(mockProcessNodesWithoutDataSource.mock.calls.length).toBeLessThanOrEqual(1) }) @@ -327,7 +303,7 @@ describe('RagPipeline', () => { graph: { nodes: [], edges: [], - viewport: undefined as any, + viewport: undefined as never, }, }) mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) @@ -342,7 +318,7 @@ describe('RagPipeline', () => { graph: { nodes: [], edges: [], - viewport: null as any, + viewport: null as never, }, }) mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) @@ -438,7 +414,7 @@ describe('processNodesWithoutDataSource utility integration', () => { const mockData = createMockWorkflowData() mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) mockProcessNodesWithoutDataSource.mockReturnValue({ - nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as any, + nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as unknown as ReturnType['nodes'], viewport: { x: 0, y: 0, zoom: 2 }, }) @@ -467,14 +443,11 @@ describe('Conditional Rendering Flow', () => { it('should transition from loading to loaded state', () => { mockSelectorWithDataset('pipeline-123') - // Start with loading state mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) const { rerender } = render() - // Real Loading component has role="status" expect(screen.getByRole('status')).toBeInTheDocument() - // Transition to loaded state const mockData = createMockWorkflowData() mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) rerender() @@ -483,7 +456,6 @@ describe('Conditional Rendering Flow', () => { }) it('should switch from Conversion to Pipeline when pipelineId becomes available', () => { - // Start without pipelineId mockSelectorWithDataset(null) mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false }) @@ -491,13 +463,11 @@ describe('Conditional Rendering Flow', () => { expect(screen.getByTestId('conversion-component')).toBeInTheDocument() - // PipelineId becomes available mockSelectorWithDataset('new-pipeline-id') mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) rerender() expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument() - // Real Loading component has role="status" expect(screen.getByRole('status')).toBeInTheDocument() }) }) @@ -510,21 +480,18 @@ describe('Error Handling', () => { it('should throw when graph nodes is null', () => { const mockData = { graph: { - nodes: null as any, - edges: null as any, + nodes: null, + edges: null, viewport: { x: 0, y: 0, zoom: 1 }, }, hash: 'test', updated_at: 123, - } as FetchWorkflowDraftResponse + } as unknown as FetchWorkflowDraftResponse mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) - // Suppress console.error for expected error const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - // Real initialNodes will throw when nodes is null - // This documents the component's current behavior - it requires valid nodes array expect(() => render()).toThrow() consoleSpy.mockRestore() @@ -538,11 +505,8 @@ describe('Error Handling', () => { mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) - // Suppress console.error for expected error const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - // When graph is undefined, component throws because data.graph.nodes is accessed - // This documents the component's current behavior - it requires graph to be present expect(() => render()).toThrow() consoleSpy.mockRestore() diff --git a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx new file mode 100644 index 0000000000..2bd20fb5c3 --- /dev/null +++ b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx @@ -0,0 +1,182 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import Conversion from '../conversion' + +const mockConvert = vi.fn() +const mockInvalidDatasetDetail = vi.fn() +vi.mock('next/navigation', () => ({ + useParams: () => ({ datasetId: 'ds-123' }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useConvertDatasetToPipeline: () => ({ + mutateAsync: mockConvert, + isPending: false, + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + datasetDetailQueryKeyPrefix: ['dataset-detail'], +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: () => mockInvalidDatasetDetail, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, ...props }: Record) => ( + + ), +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ + isShow, + onConfirm, + onCancel, + title, + }: { + isShow: boolean + onConfirm: () => void + onCancel: () => void + title: string + }) => + isShow + ? ( +
+ {title} + + +
+ ) + : null, +})) + +vi.mock('../screenshot', () => ({ + default: () =>
, +})) + +describe('Conversion', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should render conversion title and description', () => { + render() + + expect(screen.getByText('datasetPipeline.conversion.title')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.conversion.descriptionChunk1')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.conversion.descriptionChunk2')).toBeInTheDocument() + }) + + it('should render convert button', () => { + render() + + expect(screen.getByText('datasetPipeline.operations.convert')).toBeInTheDocument() + }) + + it('should render warning text', () => { + render() + + expect(screen.getByText('datasetPipeline.conversion.warning')).toBeInTheDocument() + }) + + it('should render screenshot component', () => { + render() + + expect(screen.getByTestId('screenshot')).toBeInTheDocument() + }) + + it('should show confirm modal when convert button clicked', () => { + render() + + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument() + }) + + it('should hide confirm modal when cancel is clicked', () => { + render() + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('cancel-btn')) + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) + + it('should call convert when confirm is clicked', () => { + render() + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(mockConvert).toHaveBeenCalledWith('ds-123', expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + }) + + it('should handle successful conversion', async () => { + const Toast = await import('@/app/components/base/toast') + mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { + opts.onSuccess({ status: 'success' }) + }) + + render() + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + expect(mockInvalidDatasetDetail).toHaveBeenCalled() + }) + + it('should handle failed conversion', async () => { + const Toast = await import('@/app/components/base/toast') + mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { + opts.onSuccess({ status: 'failed' }) + }) + + render() + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + it('should handle conversion error', async () => { + const Toast = await import('@/app/components/base/toast') + mockConvert.mockImplementation((_id: string, opts: { onError: () => void }) => { + opts.onError() + }) + + render() + + fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) + fireEvent.click(screen.getByTestId('confirm-btn')) + + expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) +}) diff --git a/web/app/components/rag-pipeline/components/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx similarity index 82% rename from web/app/components/rag-pipeline/components/index.spec.tsx rename to web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index e17f07303d..5c3781e8c1 100644 --- a/web/app/components/rag-pipeline/components/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -3,29 +3,19 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { createMockProviderContextValue } from '@/__mocks__/provider-context' -// ============================================================================ -// Import Components After Mocks Setup -// ============================================================================ +import Conversion from '../conversion' +import RagPipelinePanel from '../panel' +import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal' +import PublishToast from '../publish-toast' +import RagPipelineChildren from '../rag-pipeline-children' +import PipelineScreenShot from '../screenshot' -import Conversion from './conversion' -import RagPipelinePanel from './panel' -import PublishAsKnowledgePipelineModal from './publish-as-knowledge-pipeline-modal' -import PublishToast from './publish-toast' -import RagPipelineChildren from './rag-pipeline-children' -import PipelineScreenShot from './screenshot' - -// ============================================================================ -// Mock External Dependencies - All vi.mock calls must come before any imports -// ============================================================================ - -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ push: mockPush }), })) -// Mock next/image vi.mock('next/image', () => ({ default: ({ src, alt, width, height }: { src: string, alt: string, width: number, height: number }) => ( // eslint-disable-next-line next/no-img-element @@ -33,7 +23,6 @@ vi.mock('next/image', () => ({ ), })) -// Mock next/dynamic vi.mock('next/dynamic', () => ({ default: (importFn: () => Promise<{ default: React.ComponentType }>, options?: { ssr?: boolean }) => { const DynamicComponent = ({ children, ...props }: PropsWithChildren) => { @@ -44,7 +33,6 @@ vi.mock('next/dynamic', () => ({ }, })) -// Mock workflow store - using controllable state let mockShowImportDSLModal = false const mockSetShowImportDSLModal = vi.fn((value: boolean) => { mockShowImportDSLModal = value @@ -112,7 +100,6 @@ vi.mock('@/app/components/workflow/store', () => { } }) -// Mock workflow hooks - extract mock functions for assertions using vi.hoisted const { mockHandlePaneContextmenuCancel, mockExportCheck, @@ -148,8 +135,7 @@ vi.mock('@/app/components/workflow/hooks', () => { } }) -// Mock rag-pipeline hooks -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useAvailableNodesMetaData: () => ({}), useDSL: () => ({ exportCheck: mockExportCheck, @@ -178,18 +164,15 @@ vi.mock('../hooks', () => ({ }), })) -// Mock rag-pipeline search hook -vi.mock('../hooks/use-rag-pipeline-search', () => ({ +vi.mock('../../hooks/use-rag-pipeline-search', () => ({ useRagPipelineSearch: vi.fn(), })) -// Mock configs-map hook -vi.mock('../hooks/use-configs-map', () => ({ +vi.mock('../../hooks/use-configs-map', () => ({ useConfigsMap: () => ({}), })) -// Mock inspect-vars-crud hook -vi.mock('../hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../hooks/use-inspect-vars-crud', () => ({ useInspectVarsCrud: () => ({ hasNodeInspectVars: vi.fn(), hasSetInspectVar: vi.fn(), @@ -208,14 +191,12 @@ vi.mock('../hooks/use-inspect-vars-crud', () => ({ }), })) -// Mock workflow hooks for fetch-workflow-inspect-vars vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: vi.fn(), }), })) -// Mock service hooks - with controllable convert function let mockConvertFn = vi.fn() let mockIsPending = false vi.mock('@/service/use-pipeline', () => ({ @@ -253,7 +234,6 @@ vi.mock('@/service/workflow', () => ({ }), })) -// Mock event emitter context - with controllable subscription let mockEventSubscriptionCallback: ((v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null const mockUseSubscription = vi.fn((callback: (v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => { mockEventSubscriptionCallback = callback @@ -267,7 +247,6 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -// Mock toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), @@ -280,33 +259,28 @@ vi.mock('@/app/components/base/toast', () => ({ }, })) -// Mock useTheme hook vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light', }), })) -// Mock basePath vi.mock('@/utils/var', () => ({ basePath: '/public', })) -// Mock provider context vi.mock('@/context/provider-context', () => ({ useProviderContext: () => createMockProviderContextValue(), useProviderContextSelector: (selector: (state: ReturnType) => T): T => selector(createMockProviderContextValue()), })) -// Mock WorkflowWithInnerContext vi.mock('@/app/components/workflow', () => ({ WorkflowWithInnerContext: ({ children }: PropsWithChildren) => (
{children}
), })) -// Mock workflow panel vi.mock('@/app/components/workflow/panel', () => ({ default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => (
@@ -316,19 +290,16 @@ vi.mock('@/app/components/workflow/panel', () => ({ ), })) -// Mock PluginDependency -vi.mock('../../workflow/plugin-dependency', () => ({ +vi.mock('../../../workflow/plugin-dependency', () => ({ default: () =>
, })) -// Mock plugin-dependency hooks vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ usePluginDependencies: () => ({ handleCheckPluginDependencies: vi.fn().mockResolvedValue(undefined), }), })) -// Mock DSLExportConfirmModal vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[], onConfirm: () => void, onClose: () => void }) => (
@@ -339,13 +310,11 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ ), })) -// Mock workflow constants vi.mock('@/app/components/workflow/constants', () => ({ DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', })) -// Mock workflow utils vi.mock('@/app/components/workflow/utils', () => ({ initialNodes: vi.fn(nodes => nodes), initialEdges: vi.fn(edges => edges), @@ -353,7 +322,6 @@ vi.mock('@/app/components/workflow/utils', () => ({ getKeyboardKeyNameBySystem: (key: string) => key, })) -// Mock Confirm component vi.mock('@/app/components/base/confirm', () => ({ default: ({ title, content, isShow, onConfirm, onCancel, isLoading, isDisabled }: { title: string @@ -381,7 +349,6 @@ vi.mock('@/app/components/base/confirm', () => ({ : null, })) -// Mock Modal component vi.mock('@/app/components/base/modal', () => ({ default: ({ children, isShow, onClose, className }: PropsWithChildren<{ isShow: boolean @@ -396,7 +363,6 @@ vi.mock('@/app/components/base/modal', () => ({ : null, })) -// Mock Input component vi.mock('@/app/components/base/input', () => ({ default: ({ value, onChange, placeholder }: { value: string @@ -412,7 +378,6 @@ vi.mock('@/app/components/base/input', () => ({ ), })) -// Mock Textarea component vi.mock('@/app/components/base/textarea', () => ({ default: ({ value, onChange, placeholder, className }: { value: string @@ -430,7 +395,6 @@ vi.mock('@/app/components/base/textarea', () => ({ ), })) -// Mock AppIcon component vi.mock('@/app/components/base/app-icon', () => ({ default: ({ onClick, iconType, icon, background, imageUrl, className, size }: { onClick?: () => void @@ -454,7 +418,6 @@ vi.mock('@/app/components/base/app-icon', () => ({ ), })) -// Mock AppIconPicker component vi.mock('@/app/components/base/app-icon-picker', () => ({ default: ({ onSelect, onClose }: { onSelect: (item: { type: string, icon?: string, background?: string, url?: string }) => void @@ -478,7 +441,6 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({ ), })) -// Mock Uploader component vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ default: ({ file, updateFile, className, accept, displayName }: { file?: File @@ -504,25 +466,21 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ ), })) -// Mock use-context-selector vi.mock('use-context-selector', () => ({ useContext: vi.fn(() => ({ notify: vi.fn(), })), })) -// Mock RagPipelineHeader -vi.mock('./rag-pipeline-header', () => ({ +vi.mock('../rag-pipeline-header', () => ({ default: () =>
, })) -// Mock PublishToast -vi.mock('./publish-toast', () => ({ +vi.mock('../publish-toast', () => ({ default: () =>
, })) -// Mock UpdateDSLModal for RagPipelineChildren tests -vi.mock('./update-dsl-modal', () => ({ +vi.mock('../update-dsl-modal', () => ({ default: ({ onCancel, onBackup, onImport }: { onCancel: () => void onBackup: () => void @@ -536,7 +494,6 @@ vi.mock('./update-dsl-modal', () => ({ ), })) -// Mock DSLExportConfirmModal for RagPipelineChildren tests vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[] @@ -555,18 +512,11 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ ), })) -// ============================================================================ -// Test Suites -// ============================================================================ - describe('Conversion', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render conversion component without crashing', () => { render() @@ -600,9 +550,6 @@ describe('Conversion', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should show confirm modal when convert button is clicked', () => { render() @@ -617,20 +564,15 @@ describe('Conversion', () => { it('should hide confirm modal when cancel is clicked', () => { render() - // Open modal const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() - // Cancel modal fireEvent.click(screen.getByTestId('cancel-btn')) expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- - // API Callback Tests - covers lines 21-39 - // -------------------------------------------------------------------------- describe('API Callbacks', () => { beforeEach(() => { mockConvertFn = vi.fn() @@ -638,14 +580,12 @@ describe('Conversion', () => { }) it('should call convert with datasetId and show success toast on success', async () => { - // Setup mock to capture and call onSuccess callback mockConvertFn.mockImplementation((_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => { options.onSuccess({ status: 'success' }) }) render() - // Open modal and confirm const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i }) fireEvent.click(convertButton) fireEvent.click(screen.getByTestId('confirm-btn')) @@ -690,7 +630,6 @@ describe('Conversion', () => { await waitFor(() => { expect(mockConvertFn).toHaveBeenCalled() }) - // Modal should still be visible since conversion failed expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() }) @@ -711,32 +650,23 @@ describe('Conversion', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with React.memo', () => { - // Conversion is exported with React.memo expect((Conversion as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) }) it('should use useCallback for handleConvert', () => { const { rerender } = render() - // Rerender should not cause issues with callback rerender() expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle missing datasetId gracefully', () => { render() - // Component should render without crashing expect(screen.getByText('datasetPipeline.conversion.title')).toBeInTheDocument() }) }) @@ -747,9 +677,6 @@ describe('PipelineScreenShot', () => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render() @@ -770,14 +697,10 @@ describe('PipelineScreenShot', () => { render() const img = screen.getByTestId('mock-image') - // Default theme is 'light' from mock expect(img).toHaveAttribute('src', '/public/screenshots/light/Pipeline.png') }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with React.memo', () => { expect((PipelineScreenShot as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) @@ -790,9 +713,6 @@ describe('PublishToast', () => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { // Note: PublishToast is mocked, so we just verify the mock renders @@ -802,12 +722,8 @@ describe('PublishToast', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be defined', () => { - // The real PublishToast is mocked, but we can verify the import expect(PublishToast).toBeDefined() }) }) @@ -826,9 +742,6 @@ describe('PublishAsKnowledgePipelineModal', () => { onConfirm: mockOnConfirm, } - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render modal with title', () => { render() @@ -863,9 +776,6 @@ describe('PublishAsKnowledgePipelineModal', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should update name when input changes', () => { render() @@ -906,11 +816,9 @@ describe('PublishAsKnowledgePipelineModal', () => { render() - // Update values fireEvent.change(screen.getByTestId('input'), { target: { value: ' Trimmed Name ' } }) fireEvent.change(screen.getByTestId('textarea'), { target: { value: ' Trimmed Description ' } }) - // Click publish fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) expect(mockOnConfirm).toHaveBeenCalledWith( @@ -931,52 +839,39 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should update icon when emoji is selected', () => { render() - // Open picker fireEvent.click(screen.getByTestId('app-icon')) - // Select emoji fireEvent.click(screen.getByTestId('select-emoji')) - // Picker should close expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() }) it('should update icon when image is selected', () => { render() - // Open picker fireEvent.click(screen.getByTestId('app-icon')) - // Select image fireEvent.click(screen.getByTestId('select-image')) - // Picker should close expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() }) it('should close picker and restore icon when picker is closed', () => { render() - // Open picker fireEvent.click(screen.getByTestId('app-icon')) expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument() - // Close picker fireEvent.click(screen.getByTestId('close-picker')) - // Picker should close expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- - // Props Validation Tests - // -------------------------------------------------------------------------- describe('Props Validation', () => { it('should disable publish button when name is empty', () => { render() - // Clear the name fireEvent.change(screen.getByTestId('input'), { target: { value: '' } }) const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i }) @@ -986,7 +881,6 @@ describe('PublishAsKnowledgePipelineModal', () => { it('should disable publish button when name is only whitespace', () => { render() - // Set whitespace-only name fireEvent.change(screen.getByTestId('input'), { target: { value: ' ' } }) const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i }) @@ -1009,14 +903,10 @@ describe('PublishAsKnowledgePipelineModal', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should use useCallback for handleSelectIcon', () => { const { rerender } = render() - // Rerender should not cause issues rerender() expect(screen.getByTestId('app-icon')).toBeInTheDocument() }) @@ -1028,9 +918,6 @@ describe('RagPipelinePanel', () => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render panel component without crashing', () => { render() @@ -1046,9 +933,6 @@ describe('RagPipelinePanel', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with memo', () => { expect((RagPipelinePanel as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) @@ -1063,9 +947,6 @@ describe('RagPipelineChildren', () => { mockEventSubscriptionCallback = null }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render() @@ -1090,9 +971,6 @@ describe('RagPipelineChildren', () => { }) }) - // -------------------------------------------------------------------------- - // Event Subscription Tests - covers lines 37-40 - // -------------------------------------------------------------------------- describe('Event Subscription', () => { it('should subscribe to event emitter', () => { render() @@ -1103,12 +981,10 @@ describe('RagPipelineChildren', () => { it('should handle DSL_EXPORT_CHECK event and set secretEnvList', async () => { render() - // Simulate DSL_EXPORT_CHECK event const mockEnvVariables: EnvironmentVariable[] = [ { id: '1', name: 'SECRET_KEY', value: 'test-secret', value_type: 'secret' as const, description: '' }, ] - // Trigger the subscription callback if (mockEventSubscriptionCallback) { mockEventSubscriptionCallback({ type: 'DSL_EXPORT_CHECK', @@ -1116,7 +992,6 @@ describe('RagPipelineChildren', () => { }) } - // DSLExportConfirmModal should be rendered await waitFor(() => { expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument() }) @@ -1125,7 +1000,6 @@ describe('RagPipelineChildren', () => { it('should not show DSLExportConfirmModal for non-DSL_EXPORT_CHECK events', () => { render() - // Trigger a different event type if (mockEventSubscriptionCallback) { mockEventSubscriptionCallback({ type: 'OTHER_EVENT', @@ -1136,9 +1010,6 @@ describe('RagPipelineChildren', () => { }) }) - // -------------------------------------------------------------------------- - // UpdateDSLModal Handlers Tests - covers lines 48-51 - // -------------------------------------------------------------------------- describe('UpdateDSLModal Handlers', () => { beforeEach(() => { mockShowImportDSLModal = true @@ -1168,14 +1039,10 @@ describe('RagPipelineChildren', () => { }) }) - // -------------------------------------------------------------------------- - // DSLExportConfirmModal Tests - covers lines 55-60 - // -------------------------------------------------------------------------- describe('DSLExportConfirmModal', () => { it('should render DSLExportConfirmModal when secretEnvList has items', async () => { render() - // Simulate DSL_EXPORT_CHECK event with secrets const mockEnvVariables: EnvironmentVariable[] = [ { id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' }, ] @@ -1195,7 +1062,6 @@ describe('RagPipelineChildren', () => { it('should close DSLExportConfirmModal when onClose is triggered', async () => { render() - // First show the modal const mockEnvVariables: EnvironmentVariable[] = [ { id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' }, ] @@ -1211,7 +1077,6 @@ describe('RagPipelineChildren', () => { expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument() }) - // Close the modal fireEvent.click(screen.getByTestId('dsl-export-close')) await waitFor(() => { @@ -1222,7 +1087,6 @@ describe('RagPipelineChildren', () => { it('should call handleExportDSL when onConfirm is triggered', async () => { render() - // Show the modal const mockEnvVariables: EnvironmentVariable[] = [ { id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' }, ] @@ -1238,16 +1102,12 @@ describe('RagPipelineChildren', () => { expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument() }) - // Confirm export fireEvent.click(screen.getByTestId('dsl-export-confirm')) expect(mockHandleExportDSL).toHaveBeenCalledTimes(1) }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be wrapped with memo', () => { expect((RagPipelineChildren as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) @@ -1255,10 +1115,6 @@ describe('RagPipelineChildren', () => { }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() @@ -1276,17 +1132,13 @@ describe('Integration Tests', () => { />, ) - // Update name fireEvent.change(screen.getByTestId('input'), { target: { value: 'My Pipeline' } }) - // Add description fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'A great pipeline' } }) - // Change icon fireEvent.click(screen.getByTestId('app-icon')) fireEvent.click(screen.getByTestId('select-emoji')) - // Publish fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) await waitFor(() => { @@ -1304,10 +1156,6 @@ describe('Integration Tests', () => { }) }) -// ============================================================================ -// Edge Cases -// ============================================================================ - describe('Edge Cases', () => { beforeEach(() => { vi.clearAllMocks() @@ -1322,7 +1170,6 @@ describe('Edge Cases', () => { />, ) - // Clear the name const input = screen.getByTestId('input') fireEvent.change(input, { target: { value: '' } }) expect(input).toHaveValue('') @@ -1360,10 +1207,6 @@ describe('Edge Cases', () => { }) }) -// ============================================================================ -// Accessibility Tests -// ============================================================================ - describe('Accessibility', () => { describe('Conversion', () => { it('should have accessible button', () => { diff --git a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx new file mode 100644 index 0000000000..0d6687cbed --- /dev/null +++ b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx @@ -0,0 +1,244 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal' + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + knowledgeName: 'Test Pipeline', + knowledgeIcon: { + icon_type: 'emoji', + icon: '🔧', + icon_background: '#fff', + icon_url: '', + }, + }), + }), +})) + +vi.mock('@/app/components/base/modal', () => ({ + default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => + isShow ?
{children}
: null, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, disabled, ...props }: Record) => ( + + ), +})) + +vi.mock('@/app/components/base/input', () => ({ + default: ({ value, onChange, ...props }: Record) => ( + void} + {...props} + /> + ), +})) + +vi.mock('@/app/components/base/textarea', () => ({ + default: ({ value, onChange, ...props }: Record) => ( +