) => unknown) => {
+ return selector({ publishedAt: mockPublishedAt })
+ },
+}))
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+})
+
+describe('PublishToast', () => {
+ beforeEach(() => {
+ mockPublishedAt = 0
+ })
+
+ describe('rendering', () => {
+ it('should render when publishedAt is 0', () => {
+ mockPublishedAt = 0
+ render()
+
+ expect(screen.getByText('publishToast.title')).toBeInTheDocument()
+ })
+
+ it('should render toast title', () => {
+ render()
+
+ expect(screen.getByText('publishToast.title')).toBeInTheDocument()
+ })
+
+ it('should render toast description', () => {
+ render()
+
+ expect(screen.getByText('publishToast.desc')).toBeInTheDocument()
+ })
+
+ it('should not render when publishedAt is set', () => {
+ mockPublishedAt = Date.now()
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should have correct positioning classes', () => {
+ render()
+
+ const container = screen.getByText('publishToast.title').closest('.absolute')
+ expect(container).toHaveClass('bottom-[45px]', 'left-0', 'right-0', 'z-10')
+ })
+
+ it('should render info icon', () => {
+ const { container } = render()
+
+ // The RiInformation2Fill icon should be rendered
+ const iconContainer = container.querySelector('.text-text-accent')
+ expect(iconContainer).toBeInTheDocument()
+ })
+
+ it('should render close button', () => {
+ const { container } = render()
+
+ // The close button is a div with cursor-pointer, not a semantic button
+ const closeButton = container.querySelector('.cursor-pointer')
+ expect(closeButton).toBeInTheDocument()
+ })
+ })
+
+ describe('user interactions', () => {
+ it('should hide toast when close button is clicked', () => {
+ const { container } = render()
+
+ // The close button is a div with cursor-pointer, not a semantic button
+ const closeButton = container.querySelector('.cursor-pointer')
+ expect(screen.getByText('publishToast.title')).toBeInTheDocument()
+
+ fireEvent.click(closeButton!)
+
+ expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument()
+ })
+
+ it('should remain hidden after close button is clicked', () => {
+ const { container, rerender } = render()
+
+ // The close button is a div with cursor-pointer, not a semantic button
+ const closeButton = container.querySelector('.cursor-pointer')
+ fireEvent.click(closeButton!)
+
+ rerender()
+
+ expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('styling', () => {
+ it('should have gradient overlay', () => {
+ const { container } = render()
+
+ const gradientOverlay = container.querySelector('.bg-gradient-to-r')
+ expect(gradientOverlay).toBeInTheDocument()
+ })
+
+ it('should have correct toast width', () => {
+ render()
+
+ const toastContainer = screen.getByText('publishToast.title').closest('.w-\\[420px\\]')
+ expect(toastContainer).toBeInTheDocument()
+ })
+
+ it('should have rounded border', () => {
+ render()
+
+ const toastContainer = screen.getByText('publishToast.title').closest('.rounded-xl')
+ expect(toastContainer).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-main.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-main.spec.tsx
new file mode 100644
index 0000000000..3de3c3deeb
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-main.spec.tsx
@@ -0,0 +1,276 @@
+import type { PropsWithChildren } from 'react'
+import type { Edge, Node, Viewport } from 'reactflow'
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import RagPipelineMain from './rag-pipeline-main'
+
+// Mock hooks from ../hooks
+vi.mock('../hooks', () => ({
+ useAvailableNodesMetaData: () => ({ nodes: [], nodesMap: {} }),
+ useDSL: () => ({
+ exportCheck: vi.fn(),
+ handleExportDSL: vi.fn(),
+ }),
+ useGetRunAndTraceUrl: () => ({
+ getWorkflowRunAndTraceUrl: vi.fn(),
+ }),
+ useNodesSyncDraft: () => ({
+ doSyncWorkflowDraft: vi.fn(),
+ syncWorkflowDraftWhenPageClose: vi.fn(),
+ }),
+ usePipelineRefreshDraft: () => ({
+ handleRefreshWorkflowDraft: vi.fn(),
+ }),
+ usePipelineRun: () => ({
+ handleBackupDraft: vi.fn(),
+ handleLoadBackupDraft: vi.fn(),
+ handleRestoreFromPublishedWorkflow: vi.fn(),
+ handleRun: vi.fn(),
+ handleStopRun: vi.fn(),
+ }),
+ usePipelineStartRun: () => ({
+ handleStartWorkflowRun: vi.fn(),
+ handleWorkflowStartRunInWorkflow: vi.fn(),
+ }),
+}))
+
+// Mock useConfigsMap
+vi.mock('../hooks/use-configs-map', () => ({
+ useConfigsMap: () => ({
+ flowId: 'test-flow-id',
+ flowType: 'ragPipeline',
+ fileSettings: {},
+ }),
+}))
+
+// Mock useInspectVarsCrud
+vi.mock('../hooks/use-inspect-vars-crud', () => ({
+ useInspectVarsCrud: () => ({
+ hasNodeInspectVars: vi.fn(),
+ hasSetInspectVar: vi.fn(),
+ fetchInspectVarValue: vi.fn(),
+ editInspectVarValue: vi.fn(),
+ renameInspectVarName: vi.fn(),
+ appendNodeInspectVars: vi.fn(),
+ deleteInspectVar: vi.fn(),
+ deleteNodeInspectorVars: vi.fn(),
+ deleteAllInspectorVars: vi.fn(),
+ isInspectVarEdited: vi.fn(),
+ resetToLastRunVar: vi.fn(),
+ invalidateSysVarValues: vi.fn(),
+ resetConversationVar: vi.fn(),
+ invalidateConversationVarValues: vi.fn(),
+ }),
+}))
+
+// Mock workflow store
+const mockSetRagPipelineVariables = vi.fn()
+const mockSetEnvironmentVariables = vi.fn()
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: () => ({
+ setRagPipelineVariables: mockSetRagPipelineVariables,
+ setEnvironmentVariables: mockSetEnvironmentVariables,
+ }),
+ }),
+}))
+
+// Mock workflow hooks
+vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
+ useSetWorkflowVarsWithValue: () => ({
+ fetchInspectVars: vi.fn(),
+ }),
+}))
+
+// Mock WorkflowWithInnerContext
+vi.mock('@/app/components/workflow', () => ({
+ WorkflowWithInnerContext: ({ children, onWorkflowDataUpdate }: PropsWithChildren<{ onWorkflowDataUpdate?: (payload: unknown) => void }>) => (
+
+ {children}
+
+
+
+ ),
+}))
+
+// Mock RagPipelineChildren
+vi.mock('./rag-pipeline-children', () => ({
+ default: () => Children
,
+}))
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+})
+
+describe('RagPipelineMain', () => {
+ const defaultProps = {
+ nodes: [] as Node[],
+ edges: [] as Edge[],
+ viewport: { x: 0, y: 0, zoom: 1 } as Viewport,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('should render without crashing', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should render RagPipelineChildren', () => {
+ render()
+
+ expect(screen.getByTestId('rag-pipeline-children')).toBeInTheDocument()
+ })
+
+ it('should pass nodes to WorkflowWithInnerContext', () => {
+ const nodes = [{ id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: {} }] as Node[]
+
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should pass edges to WorkflowWithInnerContext', () => {
+ const edges = [{ id: 'edge-1', source: 'node-1', target: 'node-2' }] as Edge[]
+
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should pass viewport to WorkflowWithInnerContext', () => {
+ const viewport = { x: 100, y: 200, zoom: 1.5 }
+
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+ })
+
+ describe('handleWorkflowDataUpdate callback', () => {
+ it('should update rag_pipeline_variables when provided', () => {
+ render()
+
+ const button = screen.getByTestId('trigger-update')
+ button.click()
+
+ expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ id: '1', name: 'var1' }])
+ })
+
+ it('should update environment_variables when provided', () => {
+ render()
+
+ const button = screen.getByTestId('trigger-update')
+ button.click()
+
+ expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ id: '2', name: 'env1' }])
+ })
+
+ it('should only update rag_pipeline_variables when environment_variables is not provided', () => {
+ render()
+
+ const button = screen.getByTestId('trigger-update-partial')
+ button.click()
+
+ expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ id: '3', name: 'var2' }])
+ expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('hooks integration', () => {
+ it('should use useNodesSyncDraft hook', () => {
+ render()
+
+ // If the component renders, the hook was called successfully
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should use usePipelineRefreshDraft hook', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should use usePipelineRun hook', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should use usePipelineStartRun hook', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should use useAvailableNodesMetaData hook', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should use useGetRunAndTraceUrl hook', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should use useDSL hook', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should use useConfigsMap hook', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should use useInspectVarsCrud hook', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle empty nodes array', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should handle empty edges array', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+
+ it('should handle default viewport', () => {
+ render()
+
+ expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx
new file mode 100644
index 0000000000..b96d3dfb1f
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx
@@ -0,0 +1,1076 @@
+import type { PropsWithChildren } from 'react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DSLImportStatus } from '@/models/app'
+import UpdateDSLModal from './update-dsl-modal'
+
+// Mock react-i18next
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Mock use-context-selector
+const mockNotify = vi.fn()
+vi.mock('use-context-selector', () => ({
+ useContext: () => ({ notify: mockNotify }),
+}))
+
+// Mock toast context
+vi.mock('@/app/components/base/toast', () => ({
+ ToastContext: { Provider: ({ children }: PropsWithChildren) => children },
+}))
+
+// Mock event emitter
+const mockEmit = vi.fn()
+vi.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: () => ({
+ eventEmitter: { emit: mockEmit },
+ }),
+}))
+
+// Mock workflow store
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: () => ({
+ pipelineId: 'test-pipeline-id',
+ }),
+ }),
+}))
+
+// Mock workflow utils
+vi.mock('@/app/components/workflow/utils', () => ({
+ initialNodes: (nodes: unknown[]) => nodes,
+ initialEdges: (edges: unknown[]) => edges,
+}))
+
+// Mock plugin dependencies
+const mockHandleCheckPluginDependencies = vi.fn()
+vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
+ usePluginDependencies: () => ({
+ handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
+ }),
+}))
+
+// Mock pipeline service
+const mockImportDSL = vi.fn()
+const mockImportDSLConfirm = vi.fn()
+vi.mock('@/service/use-pipeline', () => ({
+ useImportPipelineDSL: () => ({ mutateAsync: mockImportDSL }),
+ useImportPipelineDSLConfirm: () => ({ mutateAsync: mockImportDSLConfirm }),
+}))
+
+// Mock workflow service
+vi.mock('@/service/workflow', () => ({
+ fetchWorkflowDraft: vi.fn().mockResolvedValue({
+ graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
+ hash: 'test-hash',
+ rag_pipeline_variables: [],
+ }),
+}))
+
+// Mock Uploader
+vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
+ default: ({ updateFile }: { updateFile: (file?: File) => void }) => (
+
+ {
+ const file = e.target.files?.[0]
+ updateFile(file)
+ }}
+ />
+
+
+ ),
+}))
+
+// Mock Button
+vi.mock('@/app/components/base/button', () => ({
+ default: ({ children, onClick, disabled, className, variant, loading }: {
+ children: React.ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ className?: string
+ variant?: string
+ loading?: boolean
+ }) => (
+
+ ),
+}))
+
+// Mock Modal
+vi.mock('@/app/components/base/modal', () => ({
+ default: ({ children, isShow, _onClose, className }: PropsWithChildren<{
+ isShow: boolean
+ _onClose: () => void
+ className?: string
+ }>) => isShow
+ ? (
+
+ {children}
+
+ )
+ : null,
+}))
+
+// Mock workflow constants
+vi.mock('@/app/components/workflow/constants', () => ({
+ WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
+}))
+
+// Mock FileReader
+class MockFileReader {
+ result: string | null = null
+ onload: ((e: { target: { result: string | null } }) => void) | null = null
+
+ readAsText(_file: File) {
+ // Simulate async file reading
+ setTimeout(() => {
+ this.result = 'test file content'
+ if (this.onload) {
+ this.onload({ target: { result: this.result } })
+ }
+ }, 0)
+ }
+}
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+})
+
+describe('UpdateDSLModal', () => {
+ const mockOnCancel = vi.fn()
+ const mockOnBackup = vi.fn()
+ const mockOnImport = vi.fn()
+ let originalFileReader: typeof FileReader
+
+ const defaultProps = {
+ onCancel: mockOnCancel,
+ onBackup: mockOnBackup,
+ onImport: mockOnImport,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ // Mock FileReader
+ originalFileReader = globalThis.FileReader
+ globalThis.FileReader = MockFileReader as unknown as typeof FileReader
+ })
+
+ afterEach(() => {
+ globalThis.FileReader = originalFileReader
+ })
+
+ describe('rendering', () => {
+ it('should render without crashing', () => {
+ render()
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+
+ it('should render title', () => {
+ render()
+
+ // The component uses t('common.importDSL', { ns: 'workflow' }) which returns 'common.importDSL'
+ expect(screen.getByText('common.importDSL')).toBeInTheDocument()
+ })
+
+ it('should render warning tip', () => {
+ render()
+
+ // The component uses t('common.importDSLTip', { ns: 'workflow' })
+ expect(screen.getByText('common.importDSLTip')).toBeInTheDocument()
+ })
+
+ it('should render uploader', () => {
+ render()
+
+ expect(screen.getByTestId('uploader')).toBeInTheDocument()
+ })
+
+ it('should render backup button', () => {
+ render()
+
+ // The component uses t('common.backupCurrentDraft', { ns: 'workflow' })
+ expect(screen.getByText('common.backupCurrentDraft')).toBeInTheDocument()
+ })
+
+ it('should render cancel button', () => {
+ render()
+
+ // The component uses t('newApp.Cancel', { ns: 'app' })
+ expect(screen.getByText('newApp.Cancel')).toBeInTheDocument()
+ })
+
+ it('should render import button', () => {
+ render()
+
+ // The component uses t('common.overwriteAndImport', { ns: 'workflow' })
+ expect(screen.getByText('common.overwriteAndImport')).toBeInTheDocument()
+ })
+
+ it('should render choose DSL section', () => {
+ render()
+
+ // The component uses t('common.chooseDSL', { ns: 'workflow' })
+ expect(screen.getByText('common.chooseDSL')).toBeInTheDocument()
+ })
+ })
+
+ describe('user interactions', () => {
+ it('should call onCancel when cancel button is clicked', () => {
+ render()
+
+ const cancelButton = screen.getByText('newApp.Cancel')
+ fireEvent.click(cancelButton)
+
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should call onBackup when backup button is clicked', () => {
+ render()
+
+ const backupButton = screen.getByText('common.backupCurrentDraft')
+ fireEvent.click(backupButton)
+
+ expect(mockOnBackup).toHaveBeenCalled()
+ })
+
+ it('should handle file upload', async () => {
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ // File should be processed
+ await waitFor(() => {
+ expect(screen.getByTestId('uploader')).toBeInTheDocument()
+ })
+ })
+
+ it('should clear file when clear button is clicked', () => {
+ render()
+
+ const clearButton = screen.getByTestId('clear-file')
+ fireEvent.click(clearButton)
+
+ // File should be cleared
+ expect(screen.getByTestId('uploader')).toBeInTheDocument()
+ })
+
+ it('should call onCancel when close icon is clicked', () => {
+ render()
+
+ // The close icon is in a div with onClick={onCancel}
+ const closeIconContainer = document.querySelector('.cursor-pointer')
+ if (closeIconContainer) {
+ fireEvent.click(closeIconContainer)
+ expect(mockOnCancel).toHaveBeenCalled()
+ }
+ })
+ })
+
+ describe('import functionality', () => {
+ it('should show import button disabled when no file is selected', () => {
+ render()
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).toBeDisabled()
+ })
+
+ it('should enable import button when file is selected', async () => {
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+ })
+
+ it('should disable import button after file is cleared', async () => {
+ render()
+
+ // First select a file
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ // Clear the file
+ const clearButton = screen.getByTestId('clear-file')
+ fireEvent.click(clearButton)
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).toBeDisabled()
+ })
+ })
+ })
+
+ describe('memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ expect((UpdateDSLModal as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle missing onImport callback', () => {
+ const props = {
+ onCancel: mockOnCancel,
+ onBackup: mockOnBackup,
+ }
+
+ render()
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+
+ it('should render import button with warning variant', () => {
+ render()
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).toHaveAttribute('data-variant', 'warning')
+ })
+
+ it('should render backup button with secondary variant', () => {
+ render()
+
+ // The backup button text is inside a nested div, so we need to find the closest button
+ const backupButtonText = screen.getByText('common.backupCurrentDraft')
+ const backupButton = backupButtonText.closest('button')
+ expect(backupButton).toHaveAttribute('data-variant', 'secondary')
+ })
+ })
+
+ describe('import flow', () => {
+ it('should call importDSL when import button is clicked with file content', async () => {
+ render()
+
+ // Select a file
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ // Wait for FileReader to process
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ // Click import button
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ // Wait for import to be called
+ await waitFor(() => {
+ expect(mockImportDSL).toHaveBeenCalled()
+ })
+ })
+
+ it('should show success notification on completed import', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ // Select a file and click import
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ }))
+ })
+ })
+
+ it('should call onCancel after successful import', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onImport after successful import', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockOnImport).toHaveBeenCalled()
+ })
+ })
+
+ it('should show warning notification on import with warnings', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'warning',
+ }))
+ })
+ })
+
+ it('should show error notification when import fails', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.FAILED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ }))
+ })
+ })
+
+ it('should show error notification when pipeline_id is missing on success', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: undefined,
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ }))
+ })
+ })
+
+ it('should show error notification when import throws exception', async () => {
+ mockImportDSL.mockRejectedValue(new Error('Import failed'))
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ // Wait for FileReader to complete (setTimeout 0) and button to be enabled
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ // Give extra time for the FileReader's setTimeout to complete
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ }))
+ })
+ })
+
+ it('should call handleCheckPluginDependencies on successful import', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('test-pipeline-id', true)
+ })
+ })
+
+ it('should emit WORKFLOW_DATA_UPDATE event after successful import', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(mockEmit).toHaveBeenCalled()
+ })
+ })
+
+ it('should show error modal when import status is PENDING', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ // Wait for the error modal to be shown after setTimeout
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+ })
+
+ it('should show version info in error modal', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ // Wait for error modal with version info
+ await waitFor(() => {
+ expect(screen.getByText('1.0.0')).toBeInTheDocument()
+ expect(screen.getByText('2.0.0')).toBeInTheDocument()
+ }, { timeout: 500 })
+ })
+
+ it('should close error modal when cancel button is clicked', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ // Wait for error modal
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+
+ // Find and click cancel button in error modal - it should be the one with secondary variant
+ const cancelButtons = screen.getAllByText('newApp.Cancel')
+ const errorModalCancelButton = cancelButtons.find(btn =>
+ btn.getAttribute('data-variant') === 'secondary',
+ )
+ if (errorModalCancelButton) {
+ fireEvent.click(errorModalCancelButton)
+ }
+
+ // Modal should be closed
+ await waitFor(() => {
+ expect(screen.queryByText('newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should call importDSLConfirm when confirm button is clicked in error modal', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ mockImportDSLConfirm.mockResolvedValue({
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ // Wait for error modal
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+
+ // Click confirm button
+ const confirmButton = screen.getByText('newApp.Confirm')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-id')
+ })
+ })
+
+ it('should show success notification after confirm completes', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ mockImportDSLConfirm.mockResolvedValue({
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+
+ const confirmButton = screen.getByText('newApp.Confirm')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ }))
+ })
+ })
+
+ it('should show error notification when confirm fails with FAILED status', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ mockImportDSLConfirm.mockResolvedValue({
+ status: DSLImportStatus.FAILED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+
+ const confirmButton = screen.getByText('newApp.Confirm')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ }))
+ })
+ })
+
+ it('should show error notification when confirm throws exception', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ mockImportDSLConfirm.mockRejectedValue(new Error('Confirm failed'))
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+
+ const confirmButton = screen.getByText('newApp.Confirm')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ }))
+ })
+ })
+
+ it('should show error when confirm completes but pipeline_id is missing', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ mockImportDSLConfirm.mockResolvedValue({
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: undefined,
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+
+ const confirmButton = screen.getByText('newApp.Confirm')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ }))
+ })
+ })
+
+ it('should call onImport after confirm completes successfully', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ mockImportDSLConfirm.mockResolvedValue({
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+
+ const confirmButton = screen.getByText('newApp.Confirm')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockOnImport).toHaveBeenCalled()
+ })
+ })
+
+ it('should call handleCheckPluginDependencies after confirm', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ mockImportDSLConfirm.mockResolvedValue({
+ status: DSLImportStatus.COMPLETED,
+ pipeline_id: 'test-pipeline-id',
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+
+ const confirmButton = screen.getByText('newApp.Confirm')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('test-pipeline-id', true)
+ })
+ })
+
+ it('should handle undefined imported_dsl_version and current_dsl_version', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-id',
+ status: DSLImportStatus.PENDING,
+ pipeline_id: 'test-pipeline-id',
+ imported_dsl_version: undefined,
+ current_dsl_version: undefined,
+ })
+
+ render()
+
+ const fileInput = screen.getByTestId('file-input')
+ const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
+ fireEvent.change(fileInput, { target: { files: [file] } })
+
+ await waitFor(() => {
+ const importButton = screen.getByText('common.overwriteAndImport')
+ expect(importButton).not.toBeDisabled()
+ })
+
+ const importButton = screen.getByText('common.overwriteAndImport')
+ fireEvent.click(importButton)
+
+ // Should show error modal even with undefined versions
+ await waitFor(() => {
+ expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
+ }, { timeout: 500 })
+ })
+
+ it('should not call importDSLConfirm when importId is not set', async () => {
+ // Render without triggering PENDING status first
+ render()
+
+ // importId is not set, so confirm should not be called
+ // This is hard to test directly, but we can verify by checking the confirm flow
+ expect(mockImportDSLConfirm).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/hooks/index.spec.ts b/web/app/components/rag-pipeline/hooks/index.spec.ts
new file mode 100644
index 0000000000..7917275c18
--- /dev/null
+++ b/web/app/components/rag-pipeline/hooks/index.spec.ts
@@ -0,0 +1,536 @@
+import type { RAGPipelineVariables, VAR_TYPE_MAP } from '@/models/pipeline'
+import { renderHook } from '@testing-library/react'
+import { act } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { Resolution, TransferMethod } from '@/types/app'
+import { FlowType } from '@/types/common'
+
+// ============================================================================
+// Import hooks after mocks
+// ============================================================================
+
+import {
+ useAvailableNodesMetaData,
+ useDSL,
+ useGetRunAndTraceUrl,
+ useInputFieldPanel,
+ useNodesSyncDraft,
+ usePipelineInit,
+ usePipelineRefreshDraft,
+ usePipelineRun,
+ usePipelineStartRun,
+} from './index'
+import { useConfigsMap } from './use-configs-map'
+import { useConfigurations, useInitialData } from './use-input-fields'
+import { usePipelineTemplate } from './use-pipeline-template'
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+// Mock the workflow store
+const _mockGetState = vi.fn()
+const mockUseStore = vi.fn()
+const mockUseWorkflowStore = vi.fn()
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: Record) => unknown) => mockUseStore(selector),
+ useWorkflowStore: () => mockUseWorkflowStore(),
+}))
+
+// Mock react-i18next
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Mock toast context
+const mockNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ useToastContext: () => ({
+ notify: mockNotify,
+ }),
+}))
+
+// Mock event emitter context
+const mockEventEmit = vi.fn()
+vi.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: () => ({
+ eventEmitter: {
+ emit: mockEventEmit,
+ },
+ }),
+}))
+
+// Mock i18n docLink
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
+}))
+
+// Mock workflow constants
+vi.mock('@/app/components/workflow/constants', () => ({
+ DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
+ WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
+ START_INITIAL_POSITION: { x: 100, y: 100 },
+}))
+
+// Mock workflow constants/node
+vi.mock('@/app/components/workflow/constants/node', () => ({
+ WORKFLOW_COMMON_NODES: [
+ {
+ metaData: { type: BlockEnum.Start },
+ defaultValue: { type: BlockEnum.Start },
+ },
+ {
+ metaData: { type: BlockEnum.End },
+ defaultValue: { type: BlockEnum.End },
+ },
+ ],
+}))
+
+// Mock data source defaults
+vi.mock('@/app/components/workflow/nodes/data-source-empty/default', () => ({
+ default: {
+ metaData: { type: BlockEnum.DataSourceEmpty },
+ defaultValue: { type: BlockEnum.DataSourceEmpty },
+ },
+}))
+
+vi.mock('@/app/components/workflow/nodes/data-source/default', () => ({
+ default: {
+ metaData: { type: BlockEnum.DataSource },
+ defaultValue: { type: BlockEnum.DataSource },
+ },
+}))
+
+vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({
+ default: {
+ metaData: { type: BlockEnum.KnowledgeBase },
+ defaultValue: { type: BlockEnum.KnowledgeBase },
+ },
+}))
+
+// Mock workflow utils with all needed exports
+vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
+ const actual = await importOriginal() as Record
+ return {
+ ...actual,
+ generateNewNode: ({ id, data, position }: { id: string, data: object, position: { x: number, y: number } }) => ({
+ newNode: { id, data, position, type: 'custom' },
+ }),
+ }
+})
+
+// Mock pipeline service
+const mockExportPipelineConfig = vi.fn()
+vi.mock('@/service/use-pipeline', () => ({
+ useExportPipelineDSL: () => ({
+ mutateAsync: mockExportPipelineConfig,
+ }),
+}))
+
+// Mock workflow service
+vi.mock('@/service/workflow', () => ({
+ fetchWorkflowDraft: vi.fn().mockResolvedValue({
+ graph: { nodes: [], edges: [], viewport: {} },
+ environment_variables: [],
+ }),
+}))
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('useConfigsMap', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseStore.mockImplementation((selector: (state: Record) => unknown) => {
+ const state = {
+ pipelineId: 'test-pipeline-id',
+ fileUploadConfig: { max_file_size: 10 },
+ }
+ return selector(state)
+ })
+ })
+
+ it('should return config map with correct flowId', () => {
+ const { result } = renderHook(() => useConfigsMap())
+
+ expect(result.current.flowId).toBe('test-pipeline-id')
+ })
+
+ it('should return config map with correct flowType', () => {
+ const { result } = renderHook(() => useConfigsMap())
+
+ expect(result.current.flowType).toBe(FlowType.ragPipeline)
+ })
+
+ it('should return file settings with image config', () => {
+ const { result } = renderHook(() => useConfigsMap())
+
+ expect(result.current.fileSettings.image).toEqual({
+ enabled: false,
+ detail: Resolution.high,
+ number_limits: 3,
+ transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
+ })
+ })
+
+ it('should include fileUploadConfig from store', () => {
+ const { result } = renderHook(() => useConfigsMap())
+
+ expect(result.current.fileSettings.fileUploadConfig).toEqual({ max_file_size: 10 })
+ })
+})
+
+describe('useGetRunAndTraceUrl', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseWorkflowStore.mockReturnValue({
+ getState: () => ({
+ pipelineId: 'pipeline-123',
+ }),
+ })
+ })
+
+ it('should return getWorkflowRunAndTraceUrl function', () => {
+ const { result } = renderHook(() => useGetRunAndTraceUrl())
+
+ expect(result.current.getWorkflowRunAndTraceUrl).toBeDefined()
+ expect(typeof result.current.getWorkflowRunAndTraceUrl).toBe('function')
+ })
+
+ it('should generate correct run URL', () => {
+ const { result } = renderHook(() => useGetRunAndTraceUrl())
+
+ const { runUrl } = result.current.getWorkflowRunAndTraceUrl('run-456')
+
+ expect(runUrl).toBe('/rag/pipelines/pipeline-123/workflow-runs/run-456')
+ })
+
+ it('should generate correct trace URL', () => {
+ const { result } = renderHook(() => useGetRunAndTraceUrl())
+
+ const { traceUrl } = result.current.getWorkflowRunAndTraceUrl('run-456')
+
+ expect(traceUrl).toBe('/rag/pipelines/pipeline-123/workflow-runs/run-456/node-executions')
+ })
+})
+
+describe('useInputFieldPanel', () => {
+ const mockSetShowInputFieldPanel = vi.fn()
+ const mockSetShowInputFieldPreviewPanel = vi.fn()
+ const mockSetInputFieldEditPanelProps = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseStore.mockImplementation((selector: (state: Record) => unknown) => {
+ const state = {
+ showInputFieldPreviewPanel: false,
+ inputFieldEditPanelProps: null,
+ }
+ return selector(state)
+ })
+ mockUseWorkflowStore.mockReturnValue({
+ getState: () => ({
+ showInputFieldPreviewPanel: false,
+ setShowInputFieldPanel: mockSetShowInputFieldPanel,
+ setShowInputFieldPreviewPanel: mockSetShowInputFieldPreviewPanel,
+ setInputFieldEditPanelProps: mockSetInputFieldEditPanelProps,
+ }),
+ })
+ })
+
+ it('should return isPreviewing as false when showInputFieldPreviewPanel is false', () => {
+ const { result } = renderHook(() => useInputFieldPanel())
+
+ expect(result.current.isPreviewing).toBe(false)
+ })
+
+ it('should return isPreviewing as true when showInputFieldPreviewPanel is true', () => {
+ mockUseStore.mockImplementation((selector: (state: Record) => unknown) => {
+ const state = {
+ showInputFieldPreviewPanel: true,
+ inputFieldEditPanelProps: null,
+ }
+ return selector(state)
+ })
+
+ const { result } = renderHook(() => useInputFieldPanel())
+
+ expect(result.current.isPreviewing).toBe(true)
+ })
+
+ it('should return isEditing as false when inputFieldEditPanelProps is null', () => {
+ const { result } = renderHook(() => useInputFieldPanel())
+
+ expect(result.current.isEditing).toBe(false)
+ })
+
+ it('should return isEditing as true when inputFieldEditPanelProps exists', () => {
+ mockUseStore.mockImplementation((selector: (state: Record) => unknown) => {
+ const state = {
+ showInputFieldPreviewPanel: false,
+ inputFieldEditPanelProps: { some: 'props' },
+ }
+ return selector(state)
+ })
+
+ const { result } = renderHook(() => useInputFieldPanel())
+
+ expect(result.current.isEditing).toBe(true)
+ })
+
+ it('should call all setters when closeAllInputFieldPanels is called', () => {
+ const { result } = renderHook(() => useInputFieldPanel())
+
+ act(() => {
+ result.current.closeAllInputFieldPanels()
+ })
+
+ expect(mockSetShowInputFieldPanel).toHaveBeenCalledWith(false)
+ expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(false)
+ expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(null)
+ })
+
+ it('should toggle preview panel when toggleInputFieldPreviewPanel is called', () => {
+ const { result } = renderHook(() => useInputFieldPanel())
+
+ act(() => {
+ result.current.toggleInputFieldPreviewPanel()
+ })
+
+ expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(true)
+ })
+
+ it('should set edit panel props when toggleInputFieldEditPanel is called', () => {
+ const { result } = renderHook(() => useInputFieldPanel())
+ const editContent = { type: 'edit', data: {} }
+
+ act(() => {
+ // eslint-disable-next-line ts/no-explicit-any
+ result.current.toggleInputFieldEditPanel(editContent as any)
+ })
+
+ expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(editContent)
+ })
+})
+
+describe('useInitialData', () => {
+ it('should return empty object for empty variables', () => {
+ const { result } = renderHook(() => useInitialData([], undefined))
+
+ expect(result.current).toEqual({})
+ })
+
+ it('should handle text input type with default value', () => {
+ const variables: RAGPipelineVariables = [
+ {
+ type: 'text-input' as keyof typeof VAR_TYPE_MAP,
+ variable: 'textVar',
+ label: 'Text',
+ required: false,
+ default_value: 'default text',
+ belong_to_node_id: 'node-1',
+ },
+ ]
+
+ const { result } = renderHook(() => useInitialData(variables, undefined))
+
+ expect(result.current.textVar).toBe('default text')
+ })
+
+ it('should use lastRunInputData over default value', () => {
+ const variables: RAGPipelineVariables = [
+ {
+ type: 'text-input' as keyof typeof VAR_TYPE_MAP,
+ variable: 'textVar',
+ label: 'Text',
+ required: false,
+ default_value: 'default text',
+ belong_to_node_id: 'node-1',
+ },
+ ]
+
+ const { result } = renderHook(() => useInitialData(variables, { textVar: 'last run value' }))
+
+ expect(result.current.textVar).toBe('last run value')
+ })
+
+ it('should handle number input type with default 0', () => {
+ const variables: RAGPipelineVariables = [
+ {
+ type: 'number' as keyof typeof VAR_TYPE_MAP,
+ variable: 'numVar',
+ label: 'Number',
+ required: false,
+ belong_to_node_id: 'node-1',
+ },
+ ]
+
+ const { result } = renderHook(() => useInitialData(variables, undefined))
+
+ expect(result.current.numVar).toBe(0)
+ })
+
+ it('should handle file type with default empty array', () => {
+ const variables: RAGPipelineVariables = [
+ {
+ type: 'file' as keyof typeof VAR_TYPE_MAP,
+ variable: 'fileVar',
+ label: 'File',
+ required: false,
+ belong_to_node_id: 'node-1',
+ },
+ ]
+
+ const { result } = renderHook(() => useInitialData(variables, undefined))
+
+ expect(result.current.fileVar).toEqual([])
+ })
+})
+
+describe('useConfigurations', () => {
+ it('should return empty array for empty variables', () => {
+ const { result } = renderHook(() => useConfigurations([]))
+
+ expect(result.current).toEqual([])
+ })
+
+ it('should transform variables to configurations', () => {
+ const variables: RAGPipelineVariables = [
+ {
+ type: 'text-input' as keyof typeof VAR_TYPE_MAP,
+ variable: 'textVar',
+ label: 'Text Label',
+ required: true,
+ max_length: 100,
+ placeholder: 'Enter text',
+ tooltips: 'Help text',
+ belong_to_node_id: 'node-1',
+ },
+ ]
+
+ const { result } = renderHook(() => useConfigurations(variables))
+
+ expect(result.current.length).toBe(1)
+ expect(result.current[0].variable).toBe('textVar')
+ expect(result.current[0].label).toBe('Text Label')
+ expect(result.current[0].required).toBe(true)
+ expect(result.current[0].maxLength).toBe(100)
+ expect(result.current[0].placeholder).toBe('Enter text')
+ expect(result.current[0].tooltip).toBe('Help text')
+ })
+
+ it('should transform options correctly', () => {
+ const variables: RAGPipelineVariables = [
+ {
+ type: 'select' as keyof typeof VAR_TYPE_MAP,
+ variable: 'selectVar',
+ label: 'Select',
+ required: false,
+ options: ['option1', 'option2', 'option3'],
+ belong_to_node_id: 'node-1',
+ },
+ ]
+
+ const { result } = renderHook(() => useConfigurations(variables))
+
+ expect(result.current[0].options).toEqual([
+ { label: 'option1', value: 'option1' },
+ { label: 'option2', value: 'option2' },
+ { label: 'option3', value: 'option3' },
+ ])
+ })
+})
+
+describe('useAvailableNodesMetaData', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should return nodes array', () => {
+ const { result } = renderHook(() => useAvailableNodesMetaData())
+
+ expect(result.current.nodes).toBeDefined()
+ expect(Array.isArray(result.current.nodes)).toBe(true)
+ })
+
+ it('should return nodesMap object', () => {
+ const { result } = renderHook(() => useAvailableNodesMetaData())
+
+ expect(result.current.nodesMap).toBeDefined()
+ expect(typeof result.current.nodesMap).toBe('object')
+ })
+})
+
+describe('usePipelineTemplate', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should return nodes array with knowledge base node', () => {
+ const { result } = renderHook(() => usePipelineTemplate())
+
+ expect(result.current.nodes).toBeDefined()
+ expect(Array.isArray(result.current.nodes)).toBe(true)
+ expect(result.current.nodes.length).toBe(1)
+ })
+
+ it('should return empty edges array', () => {
+ const { result } = renderHook(() => usePipelineTemplate())
+
+ expect(result.current.edges).toEqual([])
+ })
+})
+
+describe('useDSL', () => {
+ it('should be defined and exported', () => {
+ expect(useDSL).toBeDefined()
+ expect(typeof useDSL).toBe('function')
+ })
+})
+
+describe('exports', () => {
+ it('should export useAvailableNodesMetaData', () => {
+ expect(useAvailableNodesMetaData).toBeDefined()
+ })
+
+ it('should export useDSL', () => {
+ expect(useDSL).toBeDefined()
+ })
+
+ it('should export useGetRunAndTraceUrl', () => {
+ expect(useGetRunAndTraceUrl).toBeDefined()
+ })
+
+ it('should export useInputFieldPanel', () => {
+ expect(useInputFieldPanel).toBeDefined()
+ })
+
+ it('should export useNodesSyncDraft', () => {
+ expect(useNodesSyncDraft).toBeDefined()
+ })
+
+ it('should export usePipelineInit', () => {
+ expect(usePipelineInit).toBeDefined()
+ })
+
+ it('should export usePipelineRefreshDraft', () => {
+ expect(usePipelineRefreshDraft).toBeDefined()
+ })
+
+ it('should export usePipelineRun', () => {
+ expect(usePipelineRun).toBeDefined()
+ })
+
+ it('should export usePipelineStartRun', () => {
+ expect(usePipelineStartRun).toBeDefined()
+ })
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+})
diff --git a/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts b/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts
new file mode 100644
index 0000000000..0f235516e0
--- /dev/null
+++ b/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts
@@ -0,0 +1,368 @@
+import { renderHook } from '@testing-library/react'
+import { act } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// ============================================================================
+// Import after mocks
+// ============================================================================
+
+import { useDSL } from './use-DSL'
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+// Mock react-i18next
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Mock toast context
+const mockNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ useToastContext: () => ({
+ notify: mockNotify,
+ }),
+}))
+
+// Mock event emitter context
+const mockEmit = vi.fn()
+vi.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: () => ({
+ eventEmitter: {
+ emit: mockEmit,
+ },
+ }),
+}))
+
+// Mock workflow store
+const mockWorkflowStoreGetState = vi.fn()
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: mockWorkflowStoreGetState,
+ }),
+}))
+
+// Mock useNodesSyncDraft
+const mockDoSyncWorkflowDraft = vi.fn()
+vi.mock('./use-nodes-sync-draft', () => ({
+ useNodesSyncDraft: () => ({
+ doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
+ }),
+}))
+
+// Mock pipeline service
+const mockExportPipelineConfig = vi.fn()
+vi.mock('@/service/use-pipeline', () => ({
+ useExportPipelineDSL: () => ({
+ mutateAsync: mockExportPipelineConfig,
+ }),
+}))
+
+// Mock workflow service
+const mockFetchWorkflowDraft = vi.fn()
+vi.mock('@/service/workflow', () => ({
+ fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
+}))
+
+// Mock workflow constants
+vi.mock('@/app/components/workflow/constants', () => ({
+ DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
+}))
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('useDSL', () => {
+ let mockLink: { href: string, download: string, click: ReturnType }
+ let originalCreateElement: typeof document.createElement
+ let mockCreateObjectURL: ReturnType
+ let mockRevokeObjectURL: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Create a proper mock link element
+ mockLink = {
+ href: '',
+ download: '',
+ click: vi.fn(),
+ }
+
+ // Save original and mock selectively - only intercept 'a' elements
+ originalCreateElement = document.createElement.bind(document)
+ document.createElement = vi.fn((tagName: string) => {
+ if (tagName === 'a') {
+ return mockLink as unknown as HTMLElement
+ }
+ return originalCreateElement(tagName)
+ }) as typeof document.createElement
+
+ mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
+ mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+
+ // Default store state
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ knowledgeName: 'Test Knowledge Base',
+ })
+
+ mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
+ mockExportPipelineConfig.mockResolvedValue({ data: 'yaml-content' })
+ mockFetchWorkflowDraft.mockResolvedValue({
+ environment_variables: [],
+ })
+ })
+
+ afterEach(() => {
+ document.createElement = originalCreateElement
+ mockCreateObjectURL.mockRestore()
+ mockRevokeObjectURL.mockRestore()
+ vi.clearAllMocks()
+ })
+
+ describe('hook initialization', () => {
+ it('should return exportCheck function', () => {
+ const { result } = renderHook(() => useDSL())
+
+ expect(result.current.exportCheck).toBeDefined()
+ expect(typeof result.current.exportCheck).toBe('function')
+ })
+
+ it('should return handleExportDSL function', () => {
+ const { result } = renderHook(() => useDSL())
+
+ expect(result.current.handleExportDSL).toBeDefined()
+ expect(typeof result.current.handleExportDSL).toBe('function')
+ })
+ })
+
+ describe('handleExportDSL', () => {
+ it('should not export when pipelineId is missing', async () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: undefined,
+ knowledgeName: 'Test',
+ })
+
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.handleExportDSL()
+ })
+
+ expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
+ expect(mockExportPipelineConfig).not.toHaveBeenCalled()
+ })
+
+ it('should sync workflow draft before export', async () => {
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.handleExportDSL()
+ })
+
+ expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
+ })
+
+ it('should call exportPipelineConfig with correct params', async () => {
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.handleExportDSL(true)
+ })
+
+ expect(mockExportPipelineConfig).toHaveBeenCalledWith({
+ pipelineId: 'test-pipeline-id',
+ include: true,
+ })
+ })
+
+ it('should create and download file', async () => {
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.handleExportDSL()
+ })
+
+ expect(document.createElement).toHaveBeenCalledWith('a')
+ expect(mockCreateObjectURL).toHaveBeenCalled()
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url')
+ })
+
+ it('should use correct file extension for download', async () => {
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.handleExportDSL()
+ })
+
+ expect(mockLink.download).toBe('Test Knowledge Base.pipeline')
+ })
+
+ it('should trigger download click', async () => {
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.handleExportDSL()
+ })
+
+ expect(mockLink.click).toHaveBeenCalled()
+ })
+
+ it('should show error notification on export failure', async () => {
+ mockExportPipelineConfig.mockRejectedValue(new Error('Export failed'))
+
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.handleExportDSL()
+ })
+
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'exportFailed',
+ })
+ })
+ })
+
+ describe('exportCheck', () => {
+ it('should not check when pipelineId is missing', async () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: undefined,
+ knowledgeName: 'Test',
+ })
+
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.exportCheck()
+ })
+
+ expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
+ })
+
+ it('should fetch workflow draft', async () => {
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.exportCheck()
+ })
+
+ expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
+ })
+
+ it('should directly export when no secret environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ environment_variables: [
+ { id: '1', value_type: 'string', value: 'test' },
+ ],
+ })
+
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.exportCheck()
+ })
+
+ // Should call doSyncWorkflowDraft (which means handleExportDSL was called)
+ expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
+ })
+
+ it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ environment_variables: [
+ { id: '1', value_type: 'secret', value: 'secret-value' },
+ ],
+ })
+
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.exportCheck()
+ })
+
+ expect(mockEmit).toHaveBeenCalledWith({
+ type: 'DSL_EXPORT_CHECK',
+ payload: {
+ data: [{ id: '1', value_type: 'secret', value: 'secret-value' }],
+ },
+ })
+ })
+
+ it('should show error notification on check failure', async () => {
+ mockFetchWorkflowDraft.mockRejectedValue(new Error('Fetch failed'))
+
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.exportCheck()
+ })
+
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'exportFailed',
+ })
+ })
+
+ it('should filter only secret environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ environment_variables: [
+ { id: '1', value_type: 'string', value: 'plain' },
+ { id: '2', value_type: 'secret', value: 'secret1' },
+ { id: '3', value_type: 'number', value: '123' },
+ { id: '4', value_type: 'secret', value: 'secret2' },
+ ],
+ })
+
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.exportCheck()
+ })
+
+ expect(mockEmit).toHaveBeenCalledWith({
+ type: 'DSL_EXPORT_CHECK',
+ payload: {
+ data: [
+ { id: '2', value_type: 'secret', value: 'secret1' },
+ { id: '4', value_type: 'secret', value: 'secret2' },
+ ],
+ },
+ })
+ })
+
+ it('should handle empty environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ environment_variables: [],
+ })
+
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.exportCheck()
+ })
+
+ // Should directly call handleExportDSL since no secrets
+ expect(mockEmit).not.toHaveBeenCalled()
+ expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
+ })
+
+ it('should handle undefined environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ environment_variables: undefined,
+ })
+
+ const { result } = renderHook(() => useDSL())
+
+ await act(async () => {
+ await result.current.exportCheck()
+ })
+
+ // Should directly call handleExportDSL since no secrets
+ expect(mockEmit).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts
new file mode 100644
index 0000000000..5817d187ac
--- /dev/null
+++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts
@@ -0,0 +1,469 @@
+import { renderHook } from '@testing-library/react'
+import { act } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// ============================================================================
+// Import after mocks
+// ============================================================================
+
+import { useNodesSyncDraft } from './use-nodes-sync-draft'
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+// Mock reactflow
+const mockGetNodes = vi.fn()
+const mockStoreGetState = vi.fn()
+
+vi.mock('reactflow', () => ({
+ useStoreApi: () => ({
+ getState: mockStoreGetState,
+ }),
+}))
+
+// Mock workflow store
+const mockWorkflowStoreGetState = vi.fn()
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: mockWorkflowStoreGetState,
+ }),
+}))
+
+// Mock useNodesReadOnly
+const mockGetNodesReadOnly = vi.fn()
+vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
+ useNodesReadOnly: () => ({
+ getNodesReadOnly: mockGetNodesReadOnly,
+ }),
+}))
+
+// Mock useSerialAsyncCallback - must pass through arguments
+vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
+ useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise, checkFn: () => boolean) => {
+ return (...args: unknown[]) => {
+ if (!checkFn()) {
+ return fn(...args)
+ }
+ }
+ },
+}))
+
+// Mock service
+const mockSyncWorkflowDraft = vi.fn()
+vi.mock('@/service/workflow', () => ({
+ syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params),
+}))
+
+// Mock usePipelineRefreshDraft
+const mockHandleRefreshWorkflowDraft = vi.fn()
+vi.mock('@/app/components/rag-pipeline/hooks', () => ({
+ usePipelineRefreshDraft: () => ({
+ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
+ }),
+}))
+
+// Mock API_PREFIX
+vi.mock('@/config', () => ({
+ API_PREFIX: '/api',
+}))
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('useNodesSyncDraft', () => {
+ const mockSendBeacon = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Setup navigator.sendBeacon mock
+ Object.defineProperty(navigator, 'sendBeacon', {
+ value: mockSendBeacon,
+ writable: true,
+ configurable: true,
+ })
+
+ // Default store state
+ mockStoreGetState.mockReturnValue({
+ getNodes: mockGetNodes,
+ edges: [],
+ transform: [0, 0, 1],
+ })
+
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start', _temp: true }, position: { x: 0, y: 0 } },
+ { id: 'node-2', data: { type: 'end' }, position: { x: 100, y: 0 } },
+ ])
+
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ environmentVariables: [],
+ syncWorkflowDraftHash: 'test-hash',
+ ragPipelineVariables: [],
+ setSyncWorkflowDraftHash: vi.fn(),
+ setDraftUpdatedAt: vi.fn(),
+ })
+
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockSyncWorkflowDraft.mockResolvedValue({
+ hash: 'new-hash',
+ updated_at: '2024-01-01T00:00:00Z',
+ })
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('hook initialization', () => {
+ it('should return doSyncWorkflowDraft function', () => {
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ expect(result.current.doSyncWorkflowDraft).toBeDefined()
+ expect(typeof result.current.doSyncWorkflowDraft).toBe('function')
+ })
+
+ it('should return syncWorkflowDraftWhenPageClose function', () => {
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ expect(result.current.syncWorkflowDraftWhenPageClose).toBeDefined()
+ expect(typeof result.current.syncWorkflowDraftWhenPageClose).toBe('function')
+ })
+ })
+
+ describe('syncWorkflowDraftWhenPageClose', () => {
+ it('should not call sendBeacon when nodes are read only', () => {
+ mockGetNodesReadOnly.mockReturnValue(true)
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ expect(mockSendBeacon).not.toHaveBeenCalled()
+ })
+
+ it('should call sendBeacon with correct URL and params', () => {
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ expect(mockSendBeacon).toHaveBeenCalledWith(
+ '/api/rag/pipelines/test-pipeline-id/workflows/draft',
+ expect.any(String),
+ )
+ })
+
+ it('should not call sendBeacon when pipelineId is missing', () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: undefined,
+ environmentVariables: [],
+ syncWorkflowDraftHash: 'test-hash',
+ ragPipelineVariables: [],
+ })
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ expect(mockSendBeacon).not.toHaveBeenCalled()
+ })
+
+ it('should not call sendBeacon when nodes array is empty', () => {
+ mockGetNodes.mockReturnValue([])
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ expect(mockSendBeacon).not.toHaveBeenCalled()
+ })
+
+ it('should filter out temp nodes', () => {
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start', _isTempNode: true }, position: { x: 0, y: 0 } },
+ ])
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ // Should not call sendBeacon because after filtering temp nodes, array is empty
+ expect(mockSendBeacon).not.toHaveBeenCalled()
+ })
+
+ it('should remove underscore-prefixed data keys from nodes', () => {
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start', _privateData: 'secret' }, position: { x: 0, y: 0 } },
+ ])
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ expect(mockSendBeacon).toHaveBeenCalled()
+ const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
+ expect(sentData.graph.nodes[0].data._privateData).toBeUndefined()
+ })
+ })
+
+ describe('doSyncWorkflowDraft', () => {
+ it('should not sync when nodes are read only', async () => {
+ mockGetNodesReadOnly.mockReturnValue(true)
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft()
+ })
+
+ expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
+ })
+
+ it('should call syncWorkflowDraft service', async () => {
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft()
+ })
+
+ expect(mockSyncWorkflowDraft).toHaveBeenCalled()
+ })
+
+ it('should call onSuccess callback when sync succeeds', async () => {
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+ const onSuccess = vi.fn()
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft(false, { onSuccess })
+ })
+
+ expect(onSuccess).toHaveBeenCalled()
+ })
+
+ it('should call onSettled callback after sync completes', async () => {
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+ const onSettled = vi.fn()
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft(false, { onSettled })
+ })
+
+ expect(onSettled).toHaveBeenCalled()
+ })
+
+ it('should call onError callback when sync fails', async () => {
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+ mockSyncWorkflowDraft.mockRejectedValue(new Error('Sync failed'))
+ const onError = vi.fn()
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft(false, { onError })
+ })
+
+ expect(onError).toHaveBeenCalled()
+ })
+
+ it('should update hash and draft updated at on success', async () => {
+ const mockSetSyncWorkflowDraftHash = vi.fn()
+ const mockSetDraftUpdatedAt = vi.fn()
+
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ environmentVariables: [],
+ syncWorkflowDraftHash: 'test-hash',
+ ragPipelineVariables: [],
+ setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
+ setDraftUpdatedAt: mockSetDraftUpdatedAt,
+ })
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft()
+ })
+
+ expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash')
+ expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
+ })
+
+ it('should handle draft not sync error', async () => {
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+
+ const mockJsonError = {
+ json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }),
+ bodyUsed: false,
+ }
+ mockSyncWorkflowDraft.mockRejectedValue(mockJsonError)
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft(false)
+ })
+
+ // Wait for json to be called
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled()
+ })
+
+ it('should not refresh when notRefreshWhenSyncError is true', async () => {
+ mockGetNodesReadOnly.mockReturnValue(false)
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+
+ const mockJsonError = {
+ json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }),
+ bodyUsed: false,
+ }
+ mockSyncWorkflowDraft.mockRejectedValue(mockJsonError)
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft(true)
+ })
+
+ // Wait for json to be called
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('getPostParams', () => {
+ it('should include viewport coordinates in params', () => {
+ mockStoreGetState.mockReturnValue({
+ getNodes: mockGetNodes,
+ edges: [],
+ transform: [100, 200, 1.5],
+ })
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
+ expect(sentData.graph.viewport).toEqual({ x: 100, y: 200, zoom: 1.5 })
+ })
+
+ it('should include environment variables in params', () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ environmentVariables: [{ key: 'API_KEY', value: 'secret' }],
+ syncWorkflowDraftHash: 'test-hash',
+ ragPipelineVariables: [],
+ setSyncWorkflowDraftHash: vi.fn(),
+ setDraftUpdatedAt: vi.fn(),
+ })
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
+ expect(sentData.environment_variables).toEqual([{ key: 'API_KEY', value: 'secret' }])
+ })
+
+ it('should include rag pipeline variables in params', () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ environmentVariables: [],
+ syncWorkflowDraftHash: 'test-hash',
+ ragPipelineVariables: [{ variable: 'input', type: 'text-input' }],
+ setSyncWorkflowDraftHash: vi.fn(),
+ setDraftUpdatedAt: vi.fn(),
+ })
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
+ expect(sentData.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }])
+ })
+
+ it('should remove underscore-prefixed keys from edges', () => {
+ mockStoreGetState.mockReturnValue({
+ getNodes: mockGetNodes,
+ edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { _hidden: true, visible: false } }],
+ transform: [0, 0, 1],
+ })
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
+ ])
+
+ const { result } = renderHook(() => useNodesSyncDraft())
+
+ act(() => {
+ result.current.syncWorkflowDraftWhenPageClose()
+ })
+
+ const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
+ expect(sentData.graph.edges[0].data._hidden).toBeUndefined()
+ expect(sentData.graph.edges[0].data.visible).toBe(false)
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-config.spec.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-config.spec.ts
new file mode 100644
index 0000000000..491d2828d8
--- /dev/null
+++ b/web/app/components/rag-pipeline/hooks/use-pipeline-config.spec.ts
@@ -0,0 +1,299 @@
+import { renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// ============================================================================
+// Import after mocks
+// ============================================================================
+
+import { usePipelineConfig } from './use-pipeline-config'
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+// Mock workflow store
+const mockUseStore = vi.fn()
+const mockWorkflowStoreGetState = vi.fn()
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: Record) => unknown) => mockUseStore(selector),
+ useWorkflowStore: () => ({
+ getState: mockWorkflowStoreGetState,
+ }),
+}))
+
+// Mock useWorkflowConfig
+const mockUseWorkflowConfig = vi.fn()
+vi.mock('@/service/use-workflow', () => ({
+ useWorkflowConfig: (url: string, callback: (data: unknown) => void) => mockUseWorkflowConfig(url, callback),
+}))
+
+// Mock useDataSourceList
+const mockUseDataSourceList = vi.fn()
+vi.mock('@/service/use-pipeline', () => ({
+ useDataSourceList: (enabled: boolean, callback: (data: unknown) => void) => mockUseDataSourceList(enabled, callback),
+}))
+
+// Mock basePath
+vi.mock('@/utils/var', () => ({
+ basePath: '/base',
+}))
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('usePipelineConfig', () => {
+ const mockSetNodesDefaultConfigs = vi.fn()
+ const mockSetPublishedAt = vi.fn()
+ const mockSetDataSourceList = vi.fn()
+ const mockSetFileUploadConfig = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockUseStore.mockImplementation((selector: (state: Record) => unknown) => {
+ const state = { pipelineId: 'test-pipeline-id' }
+ return selector(state)
+ })
+
+ mockWorkflowStoreGetState.mockReturnValue({
+ setNodesDefaultConfigs: mockSetNodesDefaultConfigs,
+ setPublishedAt: mockSetPublishedAt,
+ setDataSourceList: mockSetDataSourceList,
+ setFileUploadConfig: mockSetFileUploadConfig,
+ })
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('hook initialization', () => {
+ it('should render without crashing', () => {
+ expect(() => renderHook(() => usePipelineConfig())).not.toThrow()
+ })
+
+ it('should call useWorkflowConfig with correct URL for nodes default configs', () => {
+ renderHook(() => usePipelineConfig())
+
+ expect(mockUseWorkflowConfig).toHaveBeenCalledWith(
+ '/rag/pipelines/test-pipeline-id/workflows/default-workflow-block-configs',
+ expect.any(Function),
+ )
+ })
+
+ it('should call useWorkflowConfig with correct URL for published workflow', () => {
+ renderHook(() => usePipelineConfig())
+
+ expect(mockUseWorkflowConfig).toHaveBeenCalledWith(
+ '/rag/pipelines/test-pipeline-id/workflows/publish',
+ expect.any(Function),
+ )
+ })
+
+ it('should call useWorkflowConfig with correct URL for file upload config', () => {
+ renderHook(() => usePipelineConfig())
+
+ expect(mockUseWorkflowConfig).toHaveBeenCalledWith(
+ '/files/upload',
+ expect.any(Function),
+ )
+ })
+
+ it('should call useDataSourceList when pipelineId exists', () => {
+ renderHook(() => usePipelineConfig())
+
+ expect(mockUseDataSourceList).toHaveBeenCalledWith(true, expect.any(Function))
+ })
+
+ it('should call useDataSourceList with false when pipelineId is missing', () => {
+ mockUseStore.mockImplementation((selector: (state: Record) => unknown) => {
+ const state = { pipelineId: undefined }
+ return selector(state)
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ expect(mockUseDataSourceList).toHaveBeenCalledWith(false, expect.any(Function))
+ })
+
+ it('should use empty URL when pipelineId is missing for nodes configs', () => {
+ mockUseStore.mockImplementation((selector: (state: Record) => unknown) => {
+ const state = { pipelineId: undefined }
+ return selector(state)
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ expect(mockUseWorkflowConfig).toHaveBeenCalledWith('', expect.any(Function))
+ })
+ })
+
+ describe('handleUpdateNodesDefaultConfigs', () => {
+ it('should handle array format configs', () => {
+ let capturedCallback: ((data: unknown) => void) | undefined
+ mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
+ if (url.includes('default-workflow-block-configs')) {
+ capturedCallback = callback
+ }
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ const arrayConfigs = [
+ { type: 'llm', config: { model: 'gpt-4' } },
+ { type: 'code', config: { language: 'python' } },
+ ]
+
+ capturedCallback?.(arrayConfigs)
+
+ expect(mockSetNodesDefaultConfigs).toHaveBeenCalledWith({
+ llm: { model: 'gpt-4' },
+ code: { language: 'python' },
+ })
+ })
+
+ it('should handle object format configs', () => {
+ let capturedCallback: ((data: unknown) => void) | undefined
+ mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
+ if (url.includes('default-workflow-block-configs')) {
+ capturedCallback = callback
+ }
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ const objectConfigs = {
+ llm: { model: 'gpt-4' },
+ code: { language: 'python' },
+ }
+
+ capturedCallback?.(objectConfigs)
+
+ expect(mockSetNodesDefaultConfigs).toHaveBeenCalledWith(objectConfigs)
+ })
+ })
+
+ describe('handleUpdatePublishedAt', () => {
+ it('should set published at from workflow response', () => {
+ let capturedCallback: ((data: unknown) => void) | undefined
+ mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
+ if (url.includes('/publish')) {
+ capturedCallback = callback
+ }
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ capturedCallback?.({ created_at: '2024-01-01T00:00:00Z' })
+
+ expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
+ })
+
+ it('should handle undefined workflow response', () => {
+ let capturedCallback: ((data: unknown) => void) | undefined
+ mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
+ if (url.includes('/publish')) {
+ capturedCallback = callback
+ }
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ capturedCallback?.(undefined)
+
+ expect(mockSetPublishedAt).toHaveBeenCalledWith(undefined)
+ })
+ })
+
+ describe('handleUpdateDataSourceList', () => {
+ it('should set data source list', () => {
+ let capturedCallback: ((data: unknown) => void) | undefined
+ mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => {
+ capturedCallback = callback
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ const dataSourceList = [
+ { declaration: { identity: { icon: '/icon.png' } } },
+ ]
+
+ capturedCallback?.(dataSourceList)
+
+ expect(mockSetDataSourceList).toHaveBeenCalled()
+ })
+
+ it('should prepend basePath to icon if not included', () => {
+ let capturedCallback: ((data: unknown) => void) | undefined
+ mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => {
+ capturedCallback = callback
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ const dataSourceList = [
+ { declaration: { identity: { icon: '/icon.png' } } },
+ ]
+
+ capturedCallback?.(dataSourceList)
+
+ // The callback modifies the array in place
+ expect(dataSourceList[0].declaration.identity.icon).toBe('/base/icon.png')
+ })
+
+ it('should not modify icon if it already includes basePath', () => {
+ let capturedCallback: ((data: unknown) => void) | undefined
+ mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => {
+ capturedCallback = callback
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ const dataSourceList = [
+ { declaration: { identity: { icon: '/base/icon.png' } } },
+ ]
+
+ capturedCallback?.(dataSourceList)
+
+ expect(dataSourceList[0].declaration.identity.icon).toBe('/base/icon.png')
+ })
+
+ it('should handle non-string icon', () => {
+ let capturedCallback: ((data: unknown) => void) | undefined
+ mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => {
+ capturedCallback = callback
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ const dataSourceList = [
+ { declaration: { identity: { icon: { url: '/icon.png' } } } },
+ ]
+
+ capturedCallback?.(dataSourceList)
+
+ // Should not modify object icon
+ expect(dataSourceList[0].declaration.identity.icon).toEqual({ url: '/icon.png' })
+ })
+ })
+
+ describe('handleUpdateWorkflowFileUploadConfig', () => {
+ it('should set file upload config', () => {
+ let capturedCallback: ((data: unknown) => void) | undefined
+ mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
+ if (url === '/files/upload') {
+ capturedCallback = callback
+ }
+ })
+
+ renderHook(() => usePipelineConfig())
+
+ const config = { max_file_size: 10 * 1024 * 1024 }
+ capturedCallback?.(config)
+
+ expect(mockSetFileUploadConfig).toHaveBeenCalledWith(config)
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-init.spec.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-init.spec.ts
new file mode 100644
index 0000000000..3938525311
--- /dev/null
+++ b/web/app/components/rag-pipeline/hooks/use-pipeline-init.spec.ts
@@ -0,0 +1,345 @@
+import { renderHook, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// ============================================================================
+// Import after mocks
+// ============================================================================
+
+import { usePipelineInit } from './use-pipeline-init'
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+// Mock workflow store
+const mockWorkflowStoreGetState = vi.fn()
+const mockWorkflowStoreSetState = vi.fn()
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: mockWorkflowStoreGetState,
+ setState: mockWorkflowStoreSetState,
+ }),
+}))
+
+// Mock dataset detail context
+const mockUseDatasetDetailContextWithSelector = vi.fn()
+vi.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContextWithSelector: (selector: (state: Record) => unknown) =>
+ mockUseDatasetDetailContextWithSelector(selector),
+}))
+
+// Mock workflow service
+const mockFetchWorkflowDraft = vi.fn()
+const mockSyncWorkflowDraft = vi.fn()
+vi.mock('@/service/workflow', () => ({
+ fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
+ syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params),
+}))
+
+// Mock usePipelineConfig
+vi.mock('./use-pipeline-config', () => ({
+ usePipelineConfig: vi.fn(),
+}))
+
+// Mock usePipelineTemplate
+vi.mock('./use-pipeline-template', () => ({
+ usePipelineTemplate: () => ({
+ nodes: [{ id: 'template-node' }],
+ edges: [],
+ }),
+}))
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('usePipelineInit', () => {
+ const mockSetEnvSecrets = vi.fn()
+ const mockSetEnvironmentVariables = vi.fn()
+ const mockSetSyncWorkflowDraftHash = vi.fn()
+ const mockSetDraftUpdatedAt = vi.fn()
+ const mockSetToolPublished = vi.fn()
+ const mockSetRagPipelineVariables = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockWorkflowStoreGetState.mockReturnValue({
+ setEnvSecrets: mockSetEnvSecrets,
+ setEnvironmentVariables: mockSetEnvironmentVariables,
+ setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
+ setDraftUpdatedAt: mockSetDraftUpdatedAt,
+ setToolPublished: mockSetToolPublished,
+ setRagPipelineVariables: mockSetRagPipelineVariables,
+ })
+
+ mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record) => unknown) => {
+ const state = {
+ dataset: {
+ pipeline_id: 'test-pipeline-id',
+ name: 'Test Knowledge',
+ icon_info: { icon: 'test-icon' },
+ },
+ }
+ return selector(state)
+ })
+
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: {
+ nodes: [{ id: 'node-1' }],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ hash: 'test-hash',
+ updated_at: '2024-01-01T00:00:00Z',
+ tool_published: true,
+ environment_variables: [],
+ rag_pipeline_variables: [],
+ })
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('hook initialization', () => {
+ it('should return data and isLoading', async () => {
+ const { result } = renderHook(() => usePipelineInit())
+
+ expect(result.current.isLoading).toBe(true)
+ expect(result.current.data).toBeUndefined()
+ })
+
+ it('should set pipelineId in workflow store on mount', () => {
+ renderHook(() => usePipelineInit())
+
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
+ pipelineId: 'test-pipeline-id',
+ knowledgeName: 'Test Knowledge',
+ knowledgeIcon: { icon: 'test-icon' },
+ })
+ })
+ })
+
+ describe('data fetching', () => {
+ it('should fetch workflow draft on mount', async () => {
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
+ })
+ })
+
+ it('should set data after successful fetch', async () => {
+ const { result } = renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(result.current.data).toBeDefined()
+ })
+ })
+
+ it('should set isLoading to false after fetch', async () => {
+ const { result } = renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false)
+ })
+ })
+
+ it('should set draft updated at', async () => {
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
+ })
+ })
+
+ it('should set tool published status', async () => {
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockSetToolPublished).toHaveBeenCalledWith(true)
+ })
+ })
+
+ it('should set sync hash', async () => {
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('test-hash')
+ })
+ })
+ })
+
+ describe('environment variables handling', () => {
+ it('should extract secret environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: { nodes: [], edges: [], viewport: {} },
+ hash: 'test-hash',
+ updated_at: '2024-01-01T00:00:00Z',
+ tool_published: false,
+ environment_variables: [
+ { id: 'env-1', value_type: 'secret', value: 'secret-value' },
+ { id: 'env-2', value_type: 'string', value: 'plain-value' },
+ ],
+ rag_pipeline_variables: [],
+ })
+
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockSetEnvSecrets).toHaveBeenCalledWith({ 'env-1': 'secret-value' })
+ })
+ })
+
+ it('should mask secret values in environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: { nodes: [], edges: [], viewport: {} },
+ hash: 'test-hash',
+ updated_at: '2024-01-01T00:00:00Z',
+ tool_published: false,
+ environment_variables: [
+ { id: 'env-1', value_type: 'secret', value: 'secret-value' },
+ { id: 'env-2', value_type: 'string', value: 'plain-value' },
+ ],
+ rag_pipeline_variables: [],
+ })
+
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
+ { id: 'env-1', value_type: 'secret', value: '[__HIDDEN__]' },
+ { id: 'env-2', value_type: 'string', value: 'plain-value' },
+ ])
+ })
+ })
+
+ it('should handle empty environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: { nodes: [], edges: [], viewport: {} },
+ hash: 'test-hash',
+ updated_at: '2024-01-01T00:00:00Z',
+ tool_published: false,
+ environment_variables: [],
+ rag_pipeline_variables: [],
+ })
+
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockSetEnvSecrets).toHaveBeenCalledWith({})
+ expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
+ })
+ })
+ })
+
+ describe('rag pipeline variables handling', () => {
+ it('should set rag pipeline variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: { nodes: [], edges: [], viewport: {} },
+ hash: 'test-hash',
+ updated_at: '2024-01-01T00:00:00Z',
+ tool_published: false,
+ environment_variables: [],
+ rag_pipeline_variables: [
+ { variable: 'query', type: 'text-input' },
+ ],
+ })
+
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([
+ { variable: 'query', type: 'text-input' },
+ ])
+ })
+ })
+
+ it('should handle undefined rag pipeline variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: { nodes: [], edges: [], viewport: {} },
+ hash: 'test-hash',
+ updated_at: '2024-01-01T00:00:00Z',
+ tool_published: false,
+ environment_variables: [],
+ rag_pipeline_variables: undefined,
+ })
+
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([])
+ })
+ })
+ })
+
+ describe('draft not exist error handling', () => {
+ it('should create initial workflow when draft does not exist', async () => {
+ const mockJsonError = {
+ json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }),
+ bodyUsed: false,
+ }
+ mockFetchWorkflowDraft.mockRejectedValueOnce(mockJsonError)
+ mockSyncWorkflowDraft.mockResolvedValue({ updated_at: '2024-01-02T00:00:00Z' })
+
+ // Second fetch succeeds
+ mockFetchWorkflowDraft.mockResolvedValueOnce({
+ graph: { nodes: [], edges: [], viewport: {} },
+ hash: 'new-hash',
+ updated_at: '2024-01-02T00:00:00Z',
+ tool_published: false,
+ environment_variables: [],
+ rag_pipeline_variables: [],
+ })
+
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
+ notInitialWorkflow: true,
+ shouldAutoOpenStartNodeSelector: true,
+ })
+ })
+ })
+
+ it('should sync initial workflow with template nodes', async () => {
+ const mockJsonError = {
+ json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }),
+ bodyUsed: false,
+ }
+ mockFetchWorkflowDraft.mockRejectedValueOnce(mockJsonError)
+ mockSyncWorkflowDraft.mockResolvedValue({ updated_at: '2024-01-02T00:00:00Z' })
+
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
+ url: '/rag/pipelines/test-pipeline-id/workflows/draft',
+ params: {
+ graph: {
+ nodes: [{ id: 'template-node' }],
+ edges: [],
+ },
+ environment_variables: [],
+ },
+ })
+ })
+ })
+ })
+
+ describe('missing datasetId', () => {
+ it('should not fetch when datasetId is missing', async () => {
+ mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record) => unknown) => {
+ const state = { dataset: undefined }
+ return selector(state)
+ })
+
+ renderHook(() => usePipelineInit())
+
+ await waitFor(() => {
+ expect(mockFetchWorkflowDraft).toHaveBeenCalled()
+ })
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.spec.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.spec.ts
new file mode 100644
index 0000000000..efdb18b7d4
--- /dev/null
+++ b/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.spec.ts
@@ -0,0 +1,246 @@
+import { renderHook, waitFor } from '@testing-library/react'
+import { act } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// ============================================================================
+// Import after mocks
+// ============================================================================
+
+import { usePipelineRefreshDraft } from './use-pipeline-refresh-draft'
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+// Mock workflow store
+const mockWorkflowStoreGetState = vi.fn()
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: mockWorkflowStoreGetState,
+ }),
+}))
+
+// Mock useWorkflowUpdate
+const mockHandleUpdateWorkflowCanvas = vi.fn()
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useWorkflowUpdate: () => ({
+ handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
+ }),
+}))
+
+// Mock workflow service
+const mockFetchWorkflowDraft = vi.fn()
+vi.mock('@/service/workflow', () => ({
+ fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
+}))
+
+// Mock utils
+vi.mock('../utils', () => ({
+ processNodesWithoutDataSource: (nodes: unknown[], viewport: unknown) => ({
+ nodes,
+ viewport,
+ }),
+}))
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('usePipelineRefreshDraft', () => {
+ const mockSetSyncWorkflowDraftHash = vi.fn()
+ const mockSetIsSyncingWorkflowDraft = vi.fn()
+ const mockSetEnvironmentVariables = vi.fn()
+ const mockSetEnvSecrets = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
+ setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
+ setEnvironmentVariables: mockSetEnvironmentVariables,
+ setEnvSecrets: mockSetEnvSecrets,
+ })
+
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: {
+ nodes: [{ id: 'node-1' }],
+ edges: [{ id: 'edge-1' }],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ hash: 'new-hash',
+ environment_variables: [],
+ })
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('hook initialization', () => {
+ it('should return handleRefreshWorkflowDraft function', () => {
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ expect(result.current.handleRefreshWorkflowDraft).toBeDefined()
+ expect(typeof result.current.handleRefreshWorkflowDraft).toBe('function')
+ })
+ })
+
+ describe('handleRefreshWorkflowDraft', () => {
+ it('should set syncing state to true at start', async () => {
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ act(() => {
+ result.current.handleRefreshWorkflowDraft()
+ })
+
+ expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true)
+ })
+
+ it('should fetch workflow draft with correct URL', async () => {
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ act(() => {
+ result.current.handleRefreshWorkflowDraft()
+ })
+
+ expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
+ })
+
+ it('should update workflow canvas with response data', async () => {
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ act(() => {
+ result.current.handleRefreshWorkflowDraft()
+ })
+
+ await waitFor(() => {
+ expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalled()
+ })
+ })
+
+ it('should update sync hash after fetch', async () => {
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ act(() => {
+ result.current.handleRefreshWorkflowDraft()
+ })
+
+ await waitFor(() => {
+ expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash')
+ })
+ })
+
+ it('should set syncing state to false after completion', async () => {
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ act(() => {
+ result.current.handleRefreshWorkflowDraft()
+ })
+
+ await waitFor(() => {
+ expect(mockSetIsSyncingWorkflowDraft).toHaveBeenLastCalledWith(false)
+ })
+ })
+
+ it('should handle secret environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ hash: 'new-hash',
+ environment_variables: [
+ { id: 'env-1', value_type: 'secret', value: 'secret-value' },
+ { id: 'env-2', value_type: 'string', value: 'plain-value' },
+ ],
+ })
+
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ act(() => {
+ result.current.handleRefreshWorkflowDraft()
+ })
+
+ await waitFor(() => {
+ expect(mockSetEnvSecrets).toHaveBeenCalledWith({ 'env-1': 'secret-value' })
+ })
+ })
+
+ it('should mask secret values in environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ hash: 'new-hash',
+ environment_variables: [
+ { id: 'env-1', value_type: 'secret', value: 'secret-value' },
+ { id: 'env-2', value_type: 'string', value: 'plain-value' },
+ ],
+ })
+
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ act(() => {
+ result.current.handleRefreshWorkflowDraft()
+ })
+
+ await waitFor(() => {
+ expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
+ { id: 'env-1', value_type: 'secret', value: '[__HIDDEN__]' },
+ { id: 'env-2', value_type: 'string', value: 'plain-value' },
+ ])
+ })
+ })
+
+ it('should handle empty environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ hash: 'new-hash',
+ environment_variables: [],
+ })
+
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ act(() => {
+ result.current.handleRefreshWorkflowDraft()
+ })
+
+ await waitFor(() => {
+ expect(mockSetEnvSecrets).toHaveBeenCalledWith({})
+ expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
+ })
+ })
+
+ it('should handle undefined environment variables', async () => {
+ mockFetchWorkflowDraft.mockResolvedValue({
+ graph: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ hash: 'new-hash',
+ environment_variables: undefined,
+ })
+
+ const { result } = renderHook(() => usePipelineRefreshDraft())
+
+ act(() => {
+ result.current.handleRefreshWorkflowDraft()
+ })
+
+ await waitFor(() => {
+ expect(mockSetEnvSecrets).toHaveBeenCalledWith({})
+ expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
+ })
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts
new file mode 100644
index 0000000000..2b21001839
--- /dev/null
+++ b/web/app/components/rag-pipeline/hooks/use-pipeline-run.spec.ts
@@ -0,0 +1,825 @@
+/* eslint-disable ts/no-explicit-any */
+import { renderHook } from '@testing-library/react'
+import { act } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+
+// ============================================================================
+// Import after mocks
+// ============================================================================
+
+import { usePipelineRun } from './use-pipeline-run'
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+// Mock reactflow
+const mockStoreGetState = vi.fn()
+const mockGetViewport = vi.fn()
+vi.mock('reactflow', () => ({
+ useStoreApi: () => ({
+ getState: mockStoreGetState,
+ }),
+ useReactFlow: () => ({
+ getViewport: mockGetViewport,
+ }),
+}))
+
+// Mock workflow store
+const mockUseStore = vi.fn()
+const mockWorkflowStoreGetState = vi.fn()
+const mockWorkflowStoreSetState = vi.fn()
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: Record) => unknown) => mockUseStore(selector),
+ useWorkflowStore: () => ({
+ getState: mockWorkflowStoreGetState,
+ setState: mockWorkflowStoreSetState,
+ }),
+}))
+
+// Mock useNodesSyncDraft
+const mockDoSyncWorkflowDraft = vi.fn()
+vi.mock('./use-nodes-sync-draft', () => ({
+ useNodesSyncDraft: () => ({
+ doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
+ }),
+}))
+
+// Mock workflow hooks
+vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
+ useSetWorkflowVarsWithValue: () => ({
+ fetchInspectVars: vi.fn(),
+ }),
+}))
+
+const mockHandleUpdateWorkflowCanvas = vi.fn()
+vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({
+ useWorkflowUpdate: () => ({
+ handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
+ }),
+}))
+
+vi.mock('@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event', () => ({
+ useWorkflowRunEvent: () => ({
+ handleWorkflowStarted: vi.fn(),
+ handleWorkflowFinished: vi.fn(),
+ handleWorkflowFailed: vi.fn(),
+ handleWorkflowNodeStarted: vi.fn(),
+ handleWorkflowNodeFinished: vi.fn(),
+ handleWorkflowNodeIterationStarted: vi.fn(),
+ handleWorkflowNodeIterationNext: vi.fn(),
+ handleWorkflowNodeIterationFinished: vi.fn(),
+ handleWorkflowNodeLoopStarted: vi.fn(),
+ handleWorkflowNodeLoopNext: vi.fn(),
+ handleWorkflowNodeLoopFinished: vi.fn(),
+ handleWorkflowNodeRetry: vi.fn(),
+ handleWorkflowAgentLog: vi.fn(),
+ handleWorkflowTextChunk: vi.fn(),
+ handleWorkflowTextReplace: vi.fn(),
+ }),
+}))
+
+// Mock service
+const mockSsePost = vi.fn()
+vi.mock('@/service/base', () => ({
+ ssePost: (url: string, ...args: unknown[]) => mockSsePost(url, ...args),
+}))
+
+const mockStopWorkflowRun = vi.fn()
+vi.mock('@/service/workflow', () => ({
+ stopWorkflowRun: (url: string) => mockStopWorkflowRun(url),
+}))
+
+const mockInvalidAllLastRun = vi.fn()
+vi.mock('@/service/use-workflow', () => ({
+ useInvalidAllLastRun: () => mockInvalidAllLastRun,
+}))
+
+// Mock FlowType
+vi.mock('@/types/common', () => ({
+ FlowType: {
+ ragPipeline: 'rag-pipeline',
+ },
+}))
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('usePipelineRun', () => {
+ const mockSetNodes = vi.fn()
+ const mockGetNodes = vi.fn()
+ const mockSetBackupDraft = vi.fn()
+ const mockSetEnvironmentVariables = vi.fn()
+ const mockSetRagPipelineVariables = vi.fn()
+ const mockSetWorkflowRunningData = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Mock DOM element
+ const mockWorkflowContainer = document.createElement('div')
+ mockWorkflowContainer.id = 'workflow-container'
+ Object.defineProperty(mockWorkflowContainer, 'clientWidth', { value: 1000 })
+ Object.defineProperty(mockWorkflowContainer, 'clientHeight', { value: 800 })
+ document.body.appendChild(mockWorkflowContainer)
+
+ mockStoreGetState.mockReturnValue({
+ getNodes: mockGetNodes,
+ setNodes: mockSetNodes,
+ edges: [],
+ })
+
+ mockGetNodes.mockReturnValue([
+ { id: 'node-1', data: { type: 'start', selected: true, _runningStatus: WorkflowRunningStatus.Running } },
+ ])
+
+ mockGetViewport.mockReturnValue({ x: 0, y: 0, zoom: 1 })
+
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ backupDraft: undefined,
+ environmentVariables: [],
+ setBackupDraft: mockSetBackupDraft,
+ setEnvironmentVariables: mockSetEnvironmentVariables,
+ setRagPipelineVariables: mockSetRagPipelineVariables,
+ setWorkflowRunningData: mockSetWorkflowRunningData,
+ })
+
+ mockUseStore.mockImplementation((selector: (state: Record) => unknown) => {
+ return selector({ pipelineId: 'test-pipeline-id' })
+ })
+
+ mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ const container = document.getElementById('workflow-container')
+ if (container) {
+ document.body.removeChild(container)
+ }
+ vi.clearAllMocks()
+ })
+
+ describe('hook initialization', () => {
+ it('should return handleBackupDraft function', () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ expect(result.current.handleBackupDraft).toBeDefined()
+ expect(typeof result.current.handleBackupDraft).toBe('function')
+ })
+
+ it('should return handleLoadBackupDraft function', () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ expect(result.current.handleLoadBackupDraft).toBeDefined()
+ expect(typeof result.current.handleLoadBackupDraft).toBe('function')
+ })
+
+ it('should return handleRun function', () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ expect(result.current.handleRun).toBeDefined()
+ expect(typeof result.current.handleRun).toBe('function')
+ })
+
+ it('should return handleStopRun function', () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ expect(result.current.handleStopRun).toBeDefined()
+ expect(typeof result.current.handleStopRun).toBe('function')
+ })
+
+ it('should return handleRestoreFromPublishedWorkflow function', () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ expect(result.current.handleRestoreFromPublishedWorkflow).toBeDefined()
+ expect(typeof result.current.handleRestoreFromPublishedWorkflow).toBe('function')
+ })
+ })
+
+ describe('handleBackupDraft', () => {
+ it('should backup draft when no backup exists', () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ act(() => {
+ result.current.handleBackupDraft()
+ })
+
+ expect(mockSetBackupDraft).toHaveBeenCalled()
+ expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
+ })
+
+ it('should not backup draft when backup already exists', () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ backupDraft: { nodes: [], edges: [], viewport: {}, environmentVariables: [] },
+ environmentVariables: [],
+ setBackupDraft: mockSetBackupDraft,
+ setEnvironmentVariables: mockSetEnvironmentVariables,
+ setRagPipelineVariables: mockSetRagPipelineVariables,
+ setWorkflowRunningData: mockSetWorkflowRunningData,
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ act(() => {
+ result.current.handleBackupDraft()
+ })
+
+ expect(mockSetBackupDraft).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('handleLoadBackupDraft', () => {
+ it('should load backup draft when exists', () => {
+ const backupDraft = {
+ nodes: [{ id: 'backup-node' }],
+ edges: [{ id: 'backup-edge' }],
+ viewport: { x: 100, y: 100, zoom: 1.5 },
+ environmentVariables: [{ key: 'ENV', value: 'test' }],
+ }
+
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ backupDraft,
+ environmentVariables: [],
+ setBackupDraft: mockSetBackupDraft,
+ setEnvironmentVariables: mockSetEnvironmentVariables,
+ setRagPipelineVariables: mockSetRagPipelineVariables,
+ setWorkflowRunningData: mockSetWorkflowRunningData,
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ act(() => {
+ result.current.handleLoadBackupDraft()
+ })
+
+ expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
+ nodes: backupDraft.nodes,
+ edges: backupDraft.edges,
+ viewport: backupDraft.viewport,
+ })
+ expect(mockSetEnvironmentVariables).toHaveBeenCalledWith(backupDraft.environmentVariables)
+ expect(mockSetBackupDraft).toHaveBeenCalledWith(undefined)
+ })
+
+ it('should not load when no backup exists', () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ pipelineId: 'test-pipeline-id',
+ backupDraft: undefined,
+ environmentVariables: [],
+ setBackupDraft: mockSetBackupDraft,
+ setEnvironmentVariables: mockSetEnvironmentVariables,
+ setRagPipelineVariables: mockSetRagPipelineVariables,
+ setWorkflowRunningData: mockSetWorkflowRunningData,
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ act(() => {
+ result.current.handleLoadBackupDraft()
+ })
+
+ expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('handleStopRun', () => {
+ it('should call stop workflow run service', () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ act(() => {
+ result.current.handleStopRun('task-123')
+ })
+
+ expect(mockStopWorkflowRun).toHaveBeenCalledWith(
+ '/rag/pipelines/test-pipeline-id/workflow-runs/tasks/task-123/stop',
+ )
+ })
+ })
+
+ describe('handleRestoreFromPublishedWorkflow', () => {
+ it('should restore workflow from published version', () => {
+ const publishedWorkflow = {
+ graph: {
+ nodes: [{ id: 'pub-node', data: { type: 'start' } }],
+ edges: [{ id: 'pub-edge' }],
+ viewport: { x: 50, y: 50, zoom: 1 },
+ },
+ environment_variables: [{ key: 'PUB_ENV', value: 'pub' }],
+ rag_pipeline_variables: [{ variable: 'input', type: 'text-input' }],
+ }
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ act(() => {
+ result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
+ })
+
+ expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
+ nodes: [{ id: 'pub-node', data: { type: 'start', selected: false }, selected: false }],
+ edges: publishedWorkflow.graph.edges,
+ viewport: publishedWorkflow.graph.viewport,
+ })
+ })
+
+ it('should set environment variables from published workflow', () => {
+ const publishedWorkflow = {
+ graph: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ environment_variables: [{ key: 'ENV', value: 'value' }],
+ rag_pipeline_variables: [],
+ }
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ act(() => {
+ result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
+ })
+
+ expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ key: 'ENV', value: 'value' }])
+ })
+
+ it('should set rag pipeline variables from published workflow', () => {
+ const publishedWorkflow = {
+ graph: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ environment_variables: [],
+ rag_pipeline_variables: [{ variable: 'query', type: 'text-input' }],
+ }
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ act(() => {
+ result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
+ })
+
+ expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }])
+ })
+
+ it('should handle empty environment and rag pipeline variables', () => {
+ const publishedWorkflow = {
+ graph: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ environment_variables: undefined,
+ rag_pipeline_variables: undefined,
+ }
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ act(() => {
+ result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
+ })
+
+ expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
+ expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([])
+ })
+ })
+
+ describe('handleRun', () => {
+ it('should sync workflow draft before running', async () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} })
+ })
+
+ expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
+ })
+
+ it('should reset node selection and running status', async () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} })
+ })
+
+ expect(mockSetNodes).toHaveBeenCalled()
+ })
+
+ it('should clear history workflow data', async () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} })
+ })
+
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ historyWorkflowData: undefined })
+ })
+
+ it('should set initial running data', async () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} })
+ })
+
+ expect(mockSetWorkflowRunningData).toHaveBeenCalledWith({
+ result: {
+ inputs_truncated: false,
+ process_data_truncated: false,
+ outputs_truncated: false,
+ status: WorkflowRunningStatus.Running,
+ },
+ tracing: [],
+ resultText: '',
+ })
+ })
+
+ it('should call ssePost with correct URL', async () => {
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: { query: 'test' } })
+ })
+
+ expect(mockSsePost).toHaveBeenCalledWith(
+ '/rag/pipelines/test-pipeline-id/workflows/draft/run',
+ expect.any(Object),
+ expect.any(Object),
+ )
+ })
+
+ it('should call onWorkflowStarted callback when provided', async () => {
+ const onWorkflowStarted = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onWorkflowStarted })
+ })
+
+ // Trigger the callback
+ await act(async () => {
+ capturedCallbacks.onWorkflowStarted?.({ task_id: 'task-1' })
+ })
+
+ expect(onWorkflowStarted).toHaveBeenCalledWith({ task_id: 'task-1' })
+ })
+
+ it('should call onWorkflowFinished callback when provided', async () => {
+ const onWorkflowFinished = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onWorkflowFinished })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onWorkflowFinished?.({ status: 'succeeded' })
+ })
+
+ expect(onWorkflowFinished).toHaveBeenCalledWith({ status: 'succeeded' })
+ })
+
+ it('should call onError callback when provided', async () => {
+ const onError = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onError })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onError?.({ message: 'error' })
+ })
+
+ expect(onError).toHaveBeenCalledWith({ message: 'error' })
+ })
+
+ it('should call onNodeStarted callback when provided', async () => {
+ const onNodeStarted = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onNodeStarted })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onNodeStarted?.({ node_id: 'node-1' })
+ })
+
+ expect(onNodeStarted).toHaveBeenCalledWith({ node_id: 'node-1' })
+ })
+
+ it('should call onNodeFinished callback when provided', async () => {
+ const onNodeFinished = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onNodeFinished })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onNodeFinished?.({ node_id: 'node-1' })
+ })
+
+ expect(onNodeFinished).toHaveBeenCalledWith({ node_id: 'node-1' })
+ })
+
+ it('should call onIterationStart callback when provided', async () => {
+ const onIterationStart = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onIterationStart })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onIterationStart?.({ iteration_id: 'iter-1' })
+ })
+
+ expect(onIterationStart).toHaveBeenCalledWith({ iteration_id: 'iter-1' })
+ })
+
+ it('should call onIterationNext callback when provided', async () => {
+ const onIterationNext = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onIterationNext })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onIterationNext?.({ index: 1 })
+ })
+
+ expect(onIterationNext).toHaveBeenCalledWith({ index: 1 })
+ })
+
+ it('should call onIterationFinish callback when provided', async () => {
+ const onIterationFinish = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onIterationFinish })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onIterationFinish?.({ iteration_id: 'iter-1' })
+ })
+
+ expect(onIterationFinish).toHaveBeenCalledWith({ iteration_id: 'iter-1' })
+ })
+
+ it('should call onLoopStart callback when provided', async () => {
+ const onLoopStart = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onLoopStart })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onLoopStart?.({ loop_id: 'loop-1' })
+ })
+
+ expect(onLoopStart).toHaveBeenCalledWith({ loop_id: 'loop-1' })
+ })
+
+ it('should call onLoopNext callback when provided', async () => {
+ const onLoopNext = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onLoopNext })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onLoopNext?.({ index: 2 })
+ })
+
+ expect(onLoopNext).toHaveBeenCalledWith({ index: 2 })
+ })
+
+ it('should call onLoopFinish callback when provided', async () => {
+ const onLoopFinish = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onLoopFinish })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onLoopFinish?.({ loop_id: 'loop-1' })
+ })
+
+ expect(onLoopFinish).toHaveBeenCalledWith({ loop_id: 'loop-1' })
+ })
+
+ it('should call onNodeRetry callback when provided', async () => {
+ const onNodeRetry = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onNodeRetry })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onNodeRetry?.({ node_id: 'node-1', retry: 1 })
+ })
+
+ expect(onNodeRetry).toHaveBeenCalledWith({ node_id: 'node-1', retry: 1 })
+ })
+
+ it('should call onAgentLog callback when provided', async () => {
+ const onAgentLog = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onAgentLog })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onAgentLog?.({ message: 'agent log' })
+ })
+
+ expect(onAgentLog).toHaveBeenCalledWith({ message: 'agent log' })
+ })
+
+ it('should handle onTextChunk callback', async () => {
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onTextChunk?.({ text: 'chunk' })
+ })
+
+ // Just verify it doesn't throw
+ expect(capturedCallbacks.onTextChunk).toBeDefined()
+ })
+
+ it('should handle onTextReplace callback', async () => {
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} })
+ })
+
+ await act(async () => {
+ capturedCallbacks.onTextReplace?.({ text: 'replaced' })
+ })
+
+ // Just verify it doesn't throw
+ expect(capturedCallbacks.onTextReplace).toBeDefined()
+ })
+
+ it('should pass rest callback to ssePost', async () => {
+ const customCallback = vi.fn()
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} }, { onData: customCallback } as any)
+ })
+
+ expect(capturedCallbacks.onData).toBeDefined()
+ })
+
+ it('should handle callbacks without optional handlers', async () => {
+ let capturedCallbacks: Record void> = {}
+
+ mockSsePost.mockImplementation((_url, _body, callbacks) => {
+ capturedCallbacks = callbacks
+ })
+
+ const { result } = renderHook(() => usePipelineRun())
+
+ // Run without any optional callbacks
+ await act(async () => {
+ await result.current.handleRun({ inputs: {} })
+ })
+
+ // Trigger all callbacks - they should not throw even without optional handlers
+ await act(async () => {
+ capturedCallbacks.onWorkflowStarted?.({ task_id: 'task-1' })
+ capturedCallbacks.onWorkflowFinished?.({ status: 'succeeded' })
+ capturedCallbacks.onError?.({ message: 'error' })
+ capturedCallbacks.onNodeStarted?.({ node_id: 'node-1' })
+ capturedCallbacks.onNodeFinished?.({ node_id: 'node-1' })
+ capturedCallbacks.onIterationStart?.({ iteration_id: 'iter-1' })
+ capturedCallbacks.onIterationNext?.({ index: 1 })
+ capturedCallbacks.onIterationFinish?.({ iteration_id: 'iter-1' })
+ capturedCallbacks.onLoopStart?.({ loop_id: 'loop-1' })
+ capturedCallbacks.onLoopNext?.({ index: 2 })
+ capturedCallbacks.onLoopFinish?.({ loop_id: 'loop-1' })
+ capturedCallbacks.onNodeRetry?.({ node_id: 'node-1', retry: 1 })
+ capturedCallbacks.onAgentLog?.({ message: 'agent log' })
+ capturedCallbacks.onTextChunk?.({ text: 'chunk' })
+ capturedCallbacks.onTextReplace?.({ text: 'replaced' })
+ })
+
+ // Verify ssePost was called
+ expect(mockSsePost).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.spec.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.spec.ts
new file mode 100644
index 0000000000..4266fb993d
--- /dev/null
+++ b/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.spec.ts
@@ -0,0 +1,217 @@
+import { renderHook } from '@testing-library/react'
+import { act } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+
+// ============================================================================
+// Import after mocks
+// ============================================================================
+
+import { usePipelineStartRun } from './use-pipeline-start-run'
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+// Mock workflow store
+const mockWorkflowStoreGetState = vi.fn()
+const mockWorkflowStoreSetState = vi.fn()
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: mockWorkflowStoreGetState,
+ setState: mockWorkflowStoreSetState,
+ }),
+}))
+
+// Mock workflow interactions
+const mockHandleCancelDebugAndPreviewPanel = vi.fn()
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useWorkflowInteractions: () => ({
+ handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
+ }),
+}))
+
+// Mock useNodesSyncDraft
+const mockDoSyncWorkflowDraft = vi.fn()
+vi.mock('@/app/components/rag-pipeline/hooks', () => ({
+ useNodesSyncDraft: () => ({
+ doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
+ }),
+ useInputFieldPanel: () => ({
+ closeAllInputFieldPanels: vi.fn(),
+ }),
+}))
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('usePipelineStartRun', () => {
+ const mockSetIsPreparingDataSource = vi.fn()
+ const mockSetShowEnvPanel = vi.fn()
+ const mockSetShowDebugAndPreviewPanel = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockWorkflowStoreGetState.mockReturnValue({
+ workflowRunningData: undefined,
+ isPreparingDataSource: false,
+ showDebugAndPreviewPanel: false,
+ setIsPreparingDataSource: mockSetIsPreparingDataSource,
+ setShowEnvPanel: mockSetShowEnvPanel,
+ setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
+ })
+
+ mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('hook initialization', () => {
+ it('should return handleStartWorkflowRun function', () => {
+ const { result } = renderHook(() => usePipelineStartRun())
+
+ expect(result.current.handleStartWorkflowRun).toBeDefined()
+ expect(typeof result.current.handleStartWorkflowRun).toBe('function')
+ })
+
+ it('should return handleWorkflowStartRunInWorkflow function', () => {
+ const { result } = renderHook(() => usePipelineStartRun())
+
+ expect(result.current.handleWorkflowStartRunInWorkflow).toBeDefined()
+ expect(typeof result.current.handleWorkflowStartRunInWorkflow).toBe('function')
+ })
+ })
+
+ describe('handleWorkflowStartRunInWorkflow', () => {
+ it('should not proceed when workflow is already running', async () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ workflowRunningData: {
+ result: { status: WorkflowRunningStatus.Running },
+ },
+ isPreparingDataSource: false,
+ showDebugAndPreviewPanel: false,
+ setIsPreparingDataSource: mockSetIsPreparingDataSource,
+ setShowEnvPanel: mockSetShowEnvPanel,
+ setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
+ })
+
+ const { result } = renderHook(() => usePipelineStartRun())
+
+ await act(async () => {
+ await result.current.handleWorkflowStartRunInWorkflow()
+ })
+
+ expect(mockSetShowEnvPanel).not.toHaveBeenCalled()
+ })
+
+ it('should set preparing data source when not preparing and has running data', async () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ workflowRunningData: {
+ result: { status: WorkflowRunningStatus.Succeeded },
+ },
+ isPreparingDataSource: false,
+ showDebugAndPreviewPanel: false,
+ setIsPreparingDataSource: mockSetIsPreparingDataSource,
+ setShowEnvPanel: mockSetShowEnvPanel,
+ setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
+ })
+
+ const { result } = renderHook(() => usePipelineStartRun())
+
+ await act(async () => {
+ await result.current.handleWorkflowStartRunInWorkflow()
+ })
+
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
+ isPreparingDataSource: true,
+ workflowRunningData: undefined,
+ })
+ })
+
+ it('should cancel debug panel when already showing', async () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ workflowRunningData: undefined,
+ isPreparingDataSource: false,
+ showDebugAndPreviewPanel: true,
+ setIsPreparingDataSource: mockSetIsPreparingDataSource,
+ setShowEnvPanel: mockSetShowEnvPanel,
+ setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
+ })
+
+ const { result } = renderHook(() => usePipelineStartRun())
+
+ await act(async () => {
+ await result.current.handleWorkflowStartRunInWorkflow()
+ })
+
+ expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
+ expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
+ })
+
+ it('should sync draft and show debug panel when conditions are met', async () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ workflowRunningData: undefined,
+ isPreparingDataSource: false,
+ showDebugAndPreviewPanel: false,
+ setIsPreparingDataSource: mockSetIsPreparingDataSource,
+ setShowEnvPanel: mockSetShowEnvPanel,
+ setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
+ })
+
+ const { result } = renderHook(() => usePipelineStartRun())
+
+ await act(async () => {
+ await result.current.handleWorkflowStartRunInWorkflow()
+ })
+
+ expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
+ expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(true)
+ expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
+ })
+
+ it('should hide env panel at start', async () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ workflowRunningData: undefined,
+ isPreparingDataSource: false,
+ showDebugAndPreviewPanel: false,
+ setIsPreparingDataSource: mockSetIsPreparingDataSource,
+ setShowEnvPanel: mockSetShowEnvPanel,
+ setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
+ })
+
+ const { result } = renderHook(() => usePipelineStartRun())
+
+ await act(async () => {
+ await result.current.handleWorkflowStartRunInWorkflow()
+ })
+
+ expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
+ })
+ })
+
+ describe('handleStartWorkflowRun', () => {
+ it('should call handleWorkflowStartRunInWorkflow', async () => {
+ mockWorkflowStoreGetState.mockReturnValue({
+ workflowRunningData: undefined,
+ isPreparingDataSource: false,
+ showDebugAndPreviewPanel: false,
+ setIsPreparingDataSource: mockSetIsPreparingDataSource,
+ setShowEnvPanel: mockSetShowEnvPanel,
+ setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
+ })
+
+ const { result } = renderHook(() => usePipelineStartRun())
+
+ await act(async () => {
+ result.current.handleStartWorkflowRun()
+ })
+
+ // Should trigger the same workflow as handleWorkflowStartRunInWorkflow
+ expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/store/index.spec.ts b/web/app/components/rag-pipeline/store/index.spec.ts
new file mode 100644
index 0000000000..c8c0a35330
--- /dev/null
+++ b/web/app/components/rag-pipeline/store/index.spec.ts
@@ -0,0 +1,289 @@
+/* eslint-disable ts/no-explicit-any */
+import type { DataSourceItem } from '@/app/components/workflow/block-selector/types'
+import { describe, expect, it, vi } from 'vitest'
+import { createRagPipelineSliceSlice } from './index'
+
+// Mock the transformDataSourceToTool function
+vi.mock('@/app/components/workflow/block-selector/utils', () => ({
+ transformDataSourceToTool: (item: DataSourceItem) => ({
+ ...item,
+ transformed: true,
+ }),
+}))
+
+describe('createRagPipelineSliceSlice', () => {
+ const mockSet = vi.fn()
+
+ describe('initial state', () => {
+ it('should have empty pipelineId', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ expect(slice.pipelineId).toBe('')
+ })
+
+ it('should have empty knowledgeName', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ expect(slice.knowledgeName).toBe('')
+ })
+
+ it('should have showInputFieldPanel as false', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ expect(slice.showInputFieldPanel).toBe(false)
+ })
+
+ it('should have showInputFieldPreviewPanel as false', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ expect(slice.showInputFieldPreviewPanel).toBe(false)
+ })
+
+ it('should have inputFieldEditPanelProps as null', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ expect(slice.inputFieldEditPanelProps).toBeNull()
+ })
+
+ it('should have empty nodesDefaultConfigs', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ expect(slice.nodesDefaultConfigs).toEqual({})
+ })
+
+ it('should have empty ragPipelineVariables', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ expect(slice.ragPipelineVariables).toEqual([])
+ })
+
+ it('should have empty dataSourceList', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ expect(slice.dataSourceList).toEqual([])
+ })
+
+ it('should have isPreparingDataSource as false', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ expect(slice.isPreparingDataSource).toBe(false)
+ })
+ })
+
+ describe('setShowInputFieldPanel', () => {
+ it('should call set with showInputFieldPanel true', () => {
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setShowInputFieldPanel(true)
+
+ expect(mockSet).toHaveBeenCalledWith(expect.any(Function))
+
+ // Get the setter function and execute it
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ showInputFieldPanel: true })
+ })
+
+ it('should call set with showInputFieldPanel false', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setShowInputFieldPanel(false)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ showInputFieldPanel: false })
+ })
+ })
+
+ describe('setShowInputFieldPreviewPanel', () => {
+ it('should call set with showInputFieldPreviewPanel true', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setShowInputFieldPreviewPanel(true)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ showInputFieldPreviewPanel: true })
+ })
+
+ it('should call set with showInputFieldPreviewPanel false', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setShowInputFieldPreviewPanel(false)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ showInputFieldPreviewPanel: false })
+ })
+ })
+
+ describe('setInputFieldEditPanelProps', () => {
+ it('should call set with inputFieldEditPanelProps object', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+ const props = { type: 'create' as const }
+
+ slice.setInputFieldEditPanelProps(props as any)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ inputFieldEditPanelProps: props })
+ })
+
+ it('should call set with inputFieldEditPanelProps null', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setInputFieldEditPanelProps(null)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ inputFieldEditPanelProps: null })
+ })
+ })
+
+ describe('setNodesDefaultConfigs', () => {
+ it('should call set with nodesDefaultConfigs', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+ const configs = { node1: { key: 'value' } }
+
+ slice.setNodesDefaultConfigs(configs)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ nodesDefaultConfigs: configs })
+ })
+
+ it('should call set with empty nodesDefaultConfigs', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setNodesDefaultConfigs({})
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ nodesDefaultConfigs: {} })
+ })
+ })
+
+ describe('setRagPipelineVariables', () => {
+ it('should call set with ragPipelineVariables', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+ const variables = [
+ { type: 'text-input', variable: 'var1', label: 'Var 1', required: true },
+ ]
+
+ slice.setRagPipelineVariables(variables as any)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ ragPipelineVariables: variables })
+ })
+
+ it('should call set with empty ragPipelineVariables', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setRagPipelineVariables([])
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ ragPipelineVariables: [] })
+ })
+ })
+
+ describe('setDataSourceList', () => {
+ it('should transform and set dataSourceList', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+ const dataSourceList: DataSourceItem[] = [
+ { name: 'source1', key: 'key1' } as unknown as DataSourceItem,
+ { name: 'source2', key: 'key2' } as unknown as DataSourceItem,
+ ]
+
+ slice.setDataSourceList(dataSourceList)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result.dataSourceList).toHaveLength(2)
+ expect(result.dataSourceList[0]).toEqual({ name: 'source1', key: 'key1', transformed: true })
+ expect(result.dataSourceList[1]).toEqual({ name: 'source2', key: 'key2', transformed: true })
+ })
+
+ it('should set empty dataSourceList', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setDataSourceList([])
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result.dataSourceList).toEqual([])
+ })
+ })
+
+ describe('setIsPreparingDataSource', () => {
+ it('should call set with isPreparingDataSource true', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setIsPreparingDataSource(true)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ isPreparingDataSource: true })
+ })
+
+ it('should call set with isPreparingDataSource false', () => {
+ mockSet.mockClear()
+ const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
+
+ slice.setIsPreparingDataSource(false)
+
+ const setterFn = mockSet.mock.calls[0][0]
+ const result = setterFn()
+ expect(result).toEqual({ isPreparingDataSource: false })
+ })
+ })
+})
+
+describe('RagPipelineSliceShape type', () => {
+ it('should define all required properties', () => {
+ const slice = createRagPipelineSliceSlice(vi.fn(), vi.fn() as any, vi.fn() as any)
+
+ // Check all properties exist
+ expect(slice).toHaveProperty('pipelineId')
+ expect(slice).toHaveProperty('knowledgeName')
+ expect(slice).toHaveProperty('showInputFieldPanel')
+ expect(slice).toHaveProperty('setShowInputFieldPanel')
+ expect(slice).toHaveProperty('showInputFieldPreviewPanel')
+ expect(slice).toHaveProperty('setShowInputFieldPreviewPanel')
+ expect(slice).toHaveProperty('inputFieldEditPanelProps')
+ expect(slice).toHaveProperty('setInputFieldEditPanelProps')
+ expect(slice).toHaveProperty('nodesDefaultConfigs')
+ expect(slice).toHaveProperty('setNodesDefaultConfigs')
+ expect(slice).toHaveProperty('ragPipelineVariables')
+ expect(slice).toHaveProperty('setRagPipelineVariables')
+ expect(slice).toHaveProperty('dataSourceList')
+ expect(slice).toHaveProperty('setDataSourceList')
+ expect(slice).toHaveProperty('isPreparingDataSource')
+ expect(slice).toHaveProperty('setIsPreparingDataSource')
+ })
+
+ it('should have all setters as functions', () => {
+ const slice = createRagPipelineSliceSlice(vi.fn(), vi.fn() as any, vi.fn() as any)
+
+ expect(typeof slice.setShowInputFieldPanel).toBe('function')
+ expect(typeof slice.setShowInputFieldPreviewPanel).toBe('function')
+ expect(typeof slice.setInputFieldEditPanelProps).toBe('function')
+ expect(typeof slice.setNodesDefaultConfigs).toBe('function')
+ expect(typeof slice.setRagPipelineVariables).toBe('function')
+ expect(typeof slice.setDataSourceList).toBe('function')
+ expect(typeof slice.setIsPreparingDataSource).toBe('function')
+ })
+})
diff --git a/web/app/components/rag-pipeline/utils/index.spec.ts b/web/app/components/rag-pipeline/utils/index.spec.ts
new file mode 100644
index 0000000000..9d816af685
--- /dev/null
+++ b/web/app/components/rag-pipeline/utils/index.spec.ts
@@ -0,0 +1,348 @@
+import type { Viewport } from 'reactflow'
+import type { Node } from '@/app/components/workflow/types'
+import { describe, expect, it, vi } from 'vitest'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { processNodesWithoutDataSource } from './nodes'
+
+// Mock constants
+vi.mock('@/app/components/workflow/constants', () => ({
+ CUSTOM_NODE: 'custom',
+ NODE_WIDTH_X_OFFSET: 400,
+ START_INITIAL_POSITION: { x: 100, y: 100 },
+}))
+
+vi.mock('@/app/components/workflow/nodes/data-source-empty/constants', () => ({
+ CUSTOM_DATA_SOURCE_EMPTY_NODE: 'data-source-empty',
+}))
+
+vi.mock('@/app/components/workflow/note-node/constants', () => ({
+ CUSTOM_NOTE_NODE: 'note',
+}))
+
+vi.mock('@/app/components/workflow/note-node/types', () => ({
+ NoteTheme: { blue: 'blue' },
+}))
+
+vi.mock('@/app/components/workflow/utils', () => ({
+ generateNewNode: ({ id, type, data, position }: { id: string, type?: string, data: object, position: { x: number, y: number } }) => ({
+ newNode: { id, type: type || 'custom', data, position },
+ }),
+}))
+
+describe('processNodesWithoutDataSource', () => {
+ describe('when nodes contain DataSource', () => {
+ it('should return original nodes and viewport unchanged', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.DataSource, title: 'Data Source' },
+ position: { x: 100, y: 100 },
+ } as Node,
+ {
+ id: 'node-2',
+ type: 'custom',
+ data: { type: BlockEnum.End, title: 'End' },
+ position: { x: 500, y: 100 },
+ } as Node,
+ ]
+ const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
+
+ const result = processNodesWithoutDataSource(nodes, viewport)
+
+ expect(result.nodes).toBe(nodes)
+ expect(result.viewport).toBe(viewport)
+ })
+
+ it('should check all nodes before returning early', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.Start, title: 'Start' },
+ position: { x: 0, y: 0 },
+ } as Node,
+ {
+ id: 'node-2',
+ type: 'custom',
+ data: { type: BlockEnum.DataSource, title: 'Data Source' },
+ position: { x: 100, y: 100 },
+ } as Node,
+ ]
+
+ const result = processNodesWithoutDataSource(nodes)
+
+ expect(result.nodes).toBe(nodes)
+ })
+ })
+
+ describe('when nodes do not contain DataSource', () => {
+ it('should add data source empty node and note node for single custom node', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'Knowledge Base' },
+ position: { x: 500, y: 200 },
+ } as Node,
+ ]
+ const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
+
+ const result = processNodesWithoutDataSource(nodes, viewport)
+
+ expect(result.nodes.length).toBe(3)
+ expect(result.nodes[0].id).toBe('data-source-empty')
+ expect(result.nodes[1].id).toBe('note')
+ expect(result.nodes[2]).toBe(nodes[0])
+ })
+
+ it('should use the leftmost custom node position for new nodes', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB 1' },
+ position: { x: 700, y: 100 },
+ } as Node,
+ {
+ id: 'node-2',
+ type: 'custom',
+ data: { type: BlockEnum.End, title: 'End' },
+ position: { x: 200, y: 100 }, // This is the leftmost
+ } as Node,
+ {
+ id: 'node-3',
+ type: 'custom',
+ data: { type: BlockEnum.Start, title: 'Start' },
+ position: { x: 500, y: 100 },
+ } as Node,
+ ]
+ const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
+
+ const result = processNodesWithoutDataSource(nodes, viewport)
+
+ // New nodes should be positioned based on the leftmost node (x: 200)
+ // startX = 200 - 400 = -200
+ expect(result.nodes[0].position.x).toBe(-200)
+ expect(result.nodes[0].position.y).toBe(100)
+ })
+
+ it('should adjust viewport based on new node position', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
+ position: { x: 300, y: 200 },
+ } as Node,
+ ]
+ const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
+
+ const result = processNodesWithoutDataSource(nodes, viewport)
+
+ // startX = 300 - 400 = -100
+ // startY = 200
+ // viewport.x = (100 - (-100)) * 1 = 200
+ // viewport.y = (100 - 200) * 1 = -100
+ expect(result.viewport).toEqual({
+ x: 200,
+ y: -100,
+ zoom: 1,
+ })
+ })
+
+ it('should apply zoom factor to viewport calculation', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
+ position: { x: 300, y: 200 },
+ } as Node,
+ ]
+ const viewport: Viewport = { x: 0, y: 0, zoom: 2 }
+
+ const result = processNodesWithoutDataSource(nodes, viewport)
+
+ // startX = 300 - 400 = -100
+ // startY = 200
+ // viewport.x = (100 - (-100)) * 2 = 400
+ // viewport.y = (100 - 200) * 2 = -200
+ expect(result.viewport).toEqual({
+ x: 400,
+ y: -200,
+ zoom: 2,
+ })
+ })
+
+ it('should use default zoom 1 when viewport zoom is undefined', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
+ position: { x: 500, y: 100 },
+ } as Node,
+ ]
+
+ const result = processNodesWithoutDataSource(nodes, undefined)
+
+ expect(result.viewport?.zoom).toBe(1)
+ })
+
+ it('should add note node below data source empty node', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
+ position: { x: 500, y: 100 },
+ } as Node,
+ ]
+
+ const result = processNodesWithoutDataSource(nodes)
+
+ // Data source empty node position
+ const dataSourceEmptyNode = result.nodes[0]
+ const noteNode = result.nodes[1]
+
+ // Note node should be 100px below data source empty node
+ expect(noteNode.position.x).toBe(dataSourceEmptyNode.position.x)
+ expect(noteNode.position.y).toBe(dataSourceEmptyNode.position.y + 100)
+ })
+
+ it('should set correct data for data source empty node', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
+ position: { x: 500, y: 100 },
+ } as Node,
+ ]
+
+ const result = processNodesWithoutDataSource(nodes)
+
+ expect(result.nodes[0].data.type).toBe(BlockEnum.DataSourceEmpty)
+ expect(result.nodes[0].data._isTempNode).toBe(true)
+ expect(result.nodes[0].data.width).toBe(240)
+ })
+
+ it('should set correct data for note node', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
+ position: { x: 500, y: 100 },
+ } as Node,
+ ]
+
+ const result = processNodesWithoutDataSource(nodes)
+
+ const noteNode = result.nodes[1]
+ const noteData = noteNode.data as Record
+ expect(noteData._isTempNode).toBe(true)
+ expect(noteData.theme).toBe('blue')
+ expect(noteData.width).toBe(240)
+ expect(noteData.height).toBe(300)
+ expect(noteData.showAuthor).toBe(true)
+ })
+ })
+
+ describe('when nodes array is empty', () => {
+ it('should return empty nodes array unchanged', () => {
+ const nodes: Node[] = []
+ const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
+
+ const result = processNodesWithoutDataSource(nodes, viewport)
+
+ expect(result.nodes).toEqual([])
+ expect(result.viewport).toBe(viewport)
+ })
+ })
+
+ describe('when no custom nodes exist', () => {
+ it('should return original nodes when only non-custom nodes', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'special', // Not 'custom'
+ data: { type: BlockEnum.Start, title: 'Start' },
+ position: { x: 100, y: 100 },
+ } as Node,
+ ]
+ const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
+
+ const result = processNodesWithoutDataSource(nodes, viewport)
+
+ // No custom nodes to find leftmost, so no new nodes are added
+ expect(result.nodes).toBe(nodes)
+ expect(result.viewport).toBe(viewport)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle nodes with same x position', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB 1' },
+ position: { x: 300, y: 100 },
+ } as Node,
+ {
+ id: 'node-2',
+ type: 'custom',
+ data: { type: BlockEnum.End, title: 'End' },
+ position: { x: 300, y: 200 },
+ } as Node,
+ ]
+
+ const result = processNodesWithoutDataSource(nodes)
+
+ // First node should be used as leftNode
+ expect(result.nodes.length).toBe(4)
+ })
+
+ it('should handle negative positions', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
+ position: { x: -100, y: -50 },
+ } as Node,
+ ]
+
+ const result = processNodesWithoutDataSource(nodes)
+
+ // startX = -100 - 400 = -500
+ expect(result.nodes[0].position.x).toBe(-500)
+ expect(result.nodes[0].position.y).toBe(-50)
+ })
+
+ it('should handle undefined viewport gracefully', () => {
+ const nodes: Node[] = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
+ position: { x: 500, y: 100 },
+ } as Node,
+ ]
+
+ const result = processNodesWithoutDataSource(nodes, undefined)
+
+ expect(result.viewport).toBeDefined()
+ expect(result.viewport?.zoom).toBe(1)
+ })
+ })
+})
+
+describe('module exports', () => {
+ it('should export processNodesWithoutDataSource', () => {
+ expect(processNodesWithoutDataSource).toBeDefined()
+ expect(typeof processNodesWithoutDataSource).toBe('function')
+ })
+})
diff --git a/web/app/components/share/text-generation/info-modal.spec.tsx b/web/app/components/share/text-generation/info-modal.spec.tsx
new file mode 100644
index 0000000000..025c5edde1
--- /dev/null
+++ b/web/app/components/share/text-generation/info-modal.spec.tsx
@@ -0,0 +1,205 @@
+import type { SiteInfo } from '@/models/share'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import InfoModal from './info-modal'
+
+// Only mock react-i18next for translations
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('InfoModal', () => {
+ const mockOnClose = vi.fn()
+
+ const baseSiteInfo: SiteInfo = {
+ title: 'Test App',
+ icon: '๐',
+ icon_type: 'emoji',
+ icon_background: '#ffffff',
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('should not render when isShow is false', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByText('Test App')).not.toBeInTheDocument()
+ })
+
+ it('should render when isShow is true', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Test App')).toBeInTheDocument()
+ })
+
+ it('should render app title', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Test App')).toBeInTheDocument()
+ })
+
+ it('should render copyright when provided', () => {
+ const siteInfoWithCopyright: SiteInfo = {
+ ...baseSiteInfo,
+ copyright: 'Dify Inc.',
+ }
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/Dify Inc./)).toBeInTheDocument()
+ })
+
+ it('should render current year in copyright', () => {
+ const siteInfoWithCopyright: SiteInfo = {
+ ...baseSiteInfo,
+ copyright: 'Test Company',
+ }
+
+ render(
+ ,
+ )
+
+ const currentYear = new Date().getFullYear().toString()
+ expect(screen.getByText(new RegExp(currentYear))).toBeInTheDocument()
+ })
+
+ it('should render custom disclaimer when provided', () => {
+ const siteInfoWithDisclaimer: SiteInfo = {
+ ...baseSiteInfo,
+ custom_disclaimer: 'This is a custom disclaimer',
+ }
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('This is a custom disclaimer')).toBeInTheDocument()
+ })
+
+ it('should not render copyright section when not provided', () => {
+ render(
+ ,
+ )
+
+ const year = new Date().getFullYear().toString()
+ expect(screen.queryByText(new RegExp(`ยฉ.*${year}`))).not.toBeInTheDocument()
+ })
+
+ it('should render with undefined data', () => {
+ render(
+ ,
+ )
+
+ // Modal should still render but without content
+ expect(screen.queryByText('Test App')).not.toBeInTheDocument()
+ })
+
+ it('should render with image icon type', () => {
+ const siteInfoWithImage: SiteInfo = {
+ ...baseSiteInfo,
+ icon_type: 'image',
+ icon_url: 'https://example.com/icon.png',
+ }
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText(siteInfoWithImage.title!)).toBeInTheDocument()
+ })
+ })
+
+ describe('close functionality', () => {
+ it('should call onClose when close button is clicked', () => {
+ render(
+ ,
+ )
+
+ // Find the close icon (RiCloseLine) which has text-text-tertiary class
+ const closeIcon = document.querySelector('[class*="text-text-tertiary"]')
+ expect(closeIcon).toBeInTheDocument()
+ if (closeIcon) {
+ fireEvent.click(closeIcon)
+ expect(mockOnClose).toHaveBeenCalled()
+ }
+ })
+ })
+
+ describe('both copyright and disclaimer', () => {
+ it('should render both when both are provided', () => {
+ const siteInfoWithBoth: SiteInfo = {
+ ...baseSiteInfo,
+ copyright: 'My Company',
+ custom_disclaimer: 'Disclaimer text here',
+ }
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/My Company/)).toBeInTheDocument()
+ expect(screen.getByText('Disclaimer text here')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/share/text-generation/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/menu-dropdown.spec.tsx
new file mode 100644
index 0000000000..b54a2df632
--- /dev/null
+++ b/web/app/components/share/text-generation/menu-dropdown.spec.tsx
@@ -0,0 +1,261 @@
+import type { SiteInfo } from '@/models/share'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import MenuDropdown from './menu-dropdown'
+
+// Mock react-i18next
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Mock next/navigation
+const mockReplace = vi.fn()
+const mockPathname = '/test-path'
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ replace: mockReplace,
+ }),
+ usePathname: () => mockPathname,
+}))
+
+// Mock web-app-context
+const mockShareCode = 'test-share-code'
+vi.mock('@/context/web-app-context', () => ({
+ useWebAppStore: (selector: (state: Record) => unknown) => {
+ const state = {
+ webAppAccessMode: 'code',
+ shareCode: mockShareCode,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock webapp-auth service
+const mockWebAppLogout = vi.fn().mockResolvedValue(undefined)
+vi.mock('@/service/webapp-auth', () => ({
+ webAppLogout: (...args: unknown[]) => mockWebAppLogout(...args),
+}))
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('MenuDropdown', () => {
+ const baseSiteInfo: SiteInfo = {
+ title: 'Test App',
+ icon: '๐',
+ icon_type: 'emoji',
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('should render the trigger button', () => {
+ render()
+
+ // The trigger button contains a settings icon (RiEqualizer2Line)
+ const triggerButton = screen.getByRole('button')
+ expect(triggerButton).toBeInTheDocument()
+ })
+
+ it('should not show dropdown content initially', () => {
+ render()
+
+ // Dropdown content should not be visible initially
+ expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
+ })
+
+ it('should show dropdown content when clicked', async () => {
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('theme.theme')).toBeInTheDocument()
+ })
+ })
+
+ it('should show About option in dropdown', async () => {
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('userProfile.about')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('privacy policy link', () => {
+ it('should show privacy policy link when provided', async () => {
+ const siteInfoWithPrivacy: SiteInfo = {
+ ...baseSiteInfo,
+ privacy_policy: 'https://example.com/privacy',
+ }
+
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('chat.privacyPolicyMiddle')).toBeInTheDocument()
+ })
+ })
+
+ it('should not show privacy policy link when not provided', async () => {
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText('chat.privacyPolicyMiddle')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should have correct href for privacy policy link', async () => {
+ const privacyUrl = 'https://example.com/privacy'
+ const siteInfoWithPrivacy: SiteInfo = {
+ ...baseSiteInfo,
+ privacy_policy: privacyUrl,
+ }
+
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ const link = screen.getByText('chat.privacyPolicyMiddle').closest('a')
+ expect(link).toHaveAttribute('href', privacyUrl)
+ expect(link).toHaveAttribute('target', '_blank')
+ })
+ })
+ })
+
+ describe('logout functionality', () => {
+ it('should show logout option when hideLogout is false', async () => {
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
+ })
+ })
+
+ it('should hide logout option when hideLogout is true', async () => {
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.queryByText('userProfile.logout')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should call webAppLogout and redirect when logout is clicked', async () => {
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
+ })
+
+ const logoutButton = screen.getByText('userProfile.logout')
+ await act(async () => {
+ fireEvent.click(logoutButton)
+ })
+
+ await waitFor(() => {
+ expect(mockWebAppLogout).toHaveBeenCalledWith(mockShareCode)
+ expect(mockReplace).toHaveBeenCalledWith(`/webapp-signin?redirect_url=${mockPathname}`)
+ })
+ })
+ })
+
+ describe('about modal', () => {
+ it('should show InfoModal when About is clicked', async () => {
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('userProfile.about')).toBeInTheDocument()
+ })
+
+ const aboutButton = screen.getByText('userProfile.about')
+ fireEvent.click(aboutButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Test App')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('forceClose prop', () => {
+ it('should close dropdown when forceClose changes to true', async () => {
+ const { rerender } = render()
+
+ const triggerButton = screen.getByRole('button')
+ fireEvent.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('theme.theme')).toBeInTheDocument()
+ })
+
+ rerender()
+
+ await waitFor(() => {
+ expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('placement prop', () => {
+ it('should accept custom placement', () => {
+ render()
+
+ const triggerButton = screen.getByRole('button')
+ expect(triggerButton).toBeInTheDocument()
+ })
+ })
+
+ describe('toggle behavior', () => {
+ it('should close dropdown when clicking trigger again', async () => {
+ render()
+
+ const triggerButton = screen.getByRole('button')
+
+ // Open
+ fireEvent.click(triggerButton)
+ await waitFor(() => {
+ expect(screen.getByText('theme.theme')).toBeInTheDocument()
+ })
+
+ // Close
+ fireEvent.click(triggerButton)
+ await waitFor(() => {
+ expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ expect((MenuDropdown as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
+ })
+ })
+})
diff --git a/web/app/components/share/text-generation/result/content.spec.tsx b/web/app/components/share/text-generation/result/content.spec.tsx
new file mode 100644
index 0000000000..242ae7aa5f
--- /dev/null
+++ b/web/app/components/share/text-generation/result/content.spec.tsx
@@ -0,0 +1,133 @@
+import type { FeedbackType } from '@/app/components/base/chat/chat/type'
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import Result from './content'
+
+// Only mock react-i18next for translations
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Mock copy-to-clipboard for the Header component
+vi.mock('copy-to-clipboard', () => ({
+ default: vi.fn(() => true),
+}))
+
+// Mock the format function from service/base
+vi.mock('@/service/base', () => ({
+ format: (content: string) => content.replace(/\n/g, '
'),
+}))
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('Result (content)', () => {
+ const mockOnFeedback = vi.fn()
+
+ const defaultProps = {
+ content: 'Test content here',
+ showFeedback: true,
+ feedback: { rating: null } as FeedbackType,
+ onFeedback: mockOnFeedback,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('should render the Header component', () => {
+ render()
+
+ // Header renders the result title
+ expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
+ })
+
+ it('should render content', () => {
+ render()
+
+ expect(screen.getByText('Test content here')).toBeInTheDocument()
+ })
+
+ it('should render formatted content with line breaks', () => {
+ render(
+ ,
+ )
+
+ // The format function converts \n to
+ const contentDiv = document.querySelector('[class*="overflow-scroll"]')
+ expect(contentDiv?.innerHTML).toContain('Line 1
Line 2')
+ })
+
+ it('should have max height style', () => {
+ render()
+
+ const contentDiv = document.querySelector('[class*="overflow-scroll"]')
+ expect(contentDiv).toHaveStyle({ maxHeight: '70vh' })
+ })
+
+ it('should render with empty content', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
+ })
+
+ it('should render with HTML content safely', () => {
+ render(
+ ,
+ )
+
+ // Content is rendered via dangerouslySetInnerHTML
+ const contentDiv = document.querySelector('[class*="overflow-scroll"]')
+ expect(contentDiv).toBeInTheDocument()
+ })
+ })
+
+ describe('feedback props', () => {
+ it('should pass showFeedback to Header', () => {
+ render(
+ ,
+ )
+
+ // Feedback buttons should not be visible
+ const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
+ expect(feedbackArea).not.toBeInTheDocument()
+ })
+
+ it('should pass feedback to Header', () => {
+ render(
+ ,
+ )
+
+ // Like button should be highlighted
+ const likeButton = document.querySelector('[class*="primary"]')
+ expect(likeButton).toBeInTheDocument()
+ })
+ })
+
+ describe('memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ expect((Result as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
+ })
+ })
+})
diff --git a/web/app/components/share/text-generation/result/header.spec.tsx b/web/app/components/share/text-generation/result/header.spec.tsx
new file mode 100644
index 0000000000..b2ef0fadc4
--- /dev/null
+++ b/web/app/components/share/text-generation/result/header.spec.tsx
@@ -0,0 +1,176 @@
+import type { FeedbackType } from '@/app/components/base/chat/chat/type'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import Header from './header'
+
+// Only mock react-i18next for translations
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Mock copy-to-clipboard
+const mockCopy = vi.fn((_text: string) => true)
+vi.mock('copy-to-clipboard', () => ({
+ default: (text: string) => mockCopy(text),
+}))
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('Header', () => {
+ const mockOnFeedback = vi.fn()
+
+ const defaultProps = {
+ result: 'Test result content',
+ showFeedback: true,
+ feedback: { rating: null } as FeedbackType,
+ onFeedback: mockOnFeedback,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('should render the result title', () => {
+ render()
+
+ expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
+ })
+
+ it('should render the copy button', () => {
+ render()
+
+ expect(screen.getByText('generation.copy')).toBeInTheDocument()
+ })
+ })
+
+ describe('copy functionality', () => {
+ it('should copy result when copy button is clicked', () => {
+ render()
+
+ const copyButton = screen.getByText('generation.copy').closest('button')
+ fireEvent.click(copyButton!)
+
+ expect(mockCopy).toHaveBeenCalledWith('Test result content')
+ })
+ })
+
+ describe('feedback buttons when showFeedback is true', () => {
+ it('should show feedback buttons when no rating is given', () => {
+ render()
+
+ // Should show both thumbs up and down buttons
+ const buttons = document.querySelectorAll('[class*="cursor-pointer"]')
+ expect(buttons.length).toBeGreaterThan(0)
+ })
+
+ it('should show like button highlighted when rating is like', () => {
+ render(
+ ,
+ )
+
+ // Should show the undo button for like
+ const likeButton = document.querySelector('[class*="primary"]')
+ expect(likeButton).toBeInTheDocument()
+ })
+
+ it('should show dislike button highlighted when rating is dislike', () => {
+ render(
+ ,
+ )
+
+ // Should show the undo button for dislike
+ const dislikeButton = document.querySelector('[class*="red"]')
+ expect(dislikeButton).toBeInTheDocument()
+ })
+
+ it('should call onFeedback with like when thumbs up is clicked', () => {
+ render()
+
+ // Find the thumbs up button (first one in the feedback area)
+ const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
+ const thumbsUp = Array.from(thumbButtons).find(btn =>
+ btn.className.includes('rounded-md') && !btn.className.includes('primary'),
+ )
+
+ if (thumbsUp) {
+ fireEvent.click(thumbsUp)
+ expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'like' })
+ }
+ })
+
+ it('should call onFeedback with dislike when thumbs down is clicked', () => {
+ render()
+
+ // Find the thumbs down button
+ const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
+ const thumbsDown = Array.from(thumbButtons).pop()
+
+ if (thumbsDown) {
+ fireEvent.click(thumbsDown)
+ expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'dislike' })
+ }
+ })
+
+ it('should call onFeedback with null when undo like is clicked', () => {
+ render(
+ ,
+ )
+
+ // When liked, clicking the like button again should undo it (has bg-primary-100 class)
+ const likeButton = document.querySelector('[class*="bg-primary-100"]')
+ expect(likeButton).toBeInTheDocument()
+ fireEvent.click(likeButton!)
+ expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
+ })
+
+ it('should call onFeedback with null when undo dislike is clicked', () => {
+ render(
+ ,
+ )
+
+ // When disliked, clicking the dislike button again should undo it (has bg-red-100 class)
+ const dislikeButton = document.querySelector('[class*="bg-red-100"]')
+ expect(dislikeButton).toBeInTheDocument()
+ fireEvent.click(dislikeButton!)
+ expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
+ })
+ })
+
+ describe('feedback buttons when showFeedback is false', () => {
+ it('should not show feedback buttons', () => {
+ render(
+ ,
+ )
+
+ // Should not show feedback area buttons (only copy button)
+ const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
+ expect(feedbackArea).not.toBeInTheDocument()
+ })
+ })
+
+ describe('memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ expect((Header as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
+ })
+ })
+})
diff --git a/web/app/components/share/text-generation/run-once/index.spec.tsx b/web/app/components/share/text-generation/run-once/index.spec.tsx
index ea5ce3c902..af3d723d20 100644
--- a/web/app/components/share/text-generation/run-once/index.spec.tsx
+++ b/web/app/components/share/text-generation/run-once/index.spec.tsx
@@ -1,6 +1,7 @@
+import type { InputValueTypes } from '../types'
import type { PromptConfig, PromptVariable } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
-import type { VisionSettings } from '@/types/app'
+import type { VisionFile, VisionSettings } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
@@ -27,7 +28,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', (
}))
vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => {
- function TextGenerationImageUploaderMock({ onFilesChange }: { onFilesChange: (files: any[]) => void }) {
+ function TextGenerationImageUploaderMock({ onFilesChange }: { onFilesChange: (files: VisionFile[]) => void }) {
useEffect(() => {
onFilesChange([])
}, [onFilesChange])
@@ -38,6 +39,20 @@ vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', (
}
})
+// Mock FileUploaderInAttachmentWrapper as it requires context providers not available in tests
+vi.mock('@/app/components/base/file-uploader', () => ({
+ FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: object[], onChange: (files: object[]) => void }) => (
+
+
+
+ {value?.length || 0}
+ {' '}
+ files
+
+
+ ),
+}))
+
const createPromptVariable = (overrides: Partial): PromptVariable => ({
key: 'input',
name: 'Input',
@@ -95,11 +110,11 @@ const setup = (overrides: {
const onInputsChange = vi.fn()
const onSend = vi.fn()
const onVisionFilesChange = vi.fn()
- let inputsRefCapture: React.MutableRefObject> | null = null
+ let inputsRefCapture: React.MutableRefObject> | null = null
const Wrapper = () => {
- const [inputs, setInputs] = useState>({})
- const inputsRef = useRef>({})
+ const [inputs, setInputs] = useState>({})
+ const inputsRef = useRef>({})
inputsRefCapture = inputsRef
return (
{
expect(stopButton).toBeDisabled()
})
+ describe('select input type', () => {
+ it('should render select input and handle selection', async () => {
+ const promptConfig: PromptConfig = {
+ prompt_template: 'template',
+ prompt_variables: [
+ createPromptVariable({
+ key: 'selectInput',
+ name: 'Select Input',
+ type: 'select',
+ options: ['Option A', 'Option B', 'Option C'],
+ default: 'Option A',
+ }),
+ ],
+ }
+ const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalledWith({
+ selectInput: 'Option A',
+ })
+ })
+ // The Select component should be rendered
+ expect(screen.getByText('Select Input')).toBeInTheDocument()
+ })
+ })
+
+ describe('file input types', () => {
+ it('should render file uploader for single file input', async () => {
+ const promptConfig: PromptConfig = {
+ prompt_template: 'template',
+ prompt_variables: [
+ createPromptVariable({
+ key: 'fileInput',
+ name: 'File Input',
+ type: 'file',
+ }),
+ ],
+ }
+ const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalledWith({
+ fileInput: undefined,
+ })
+ })
+ expect(screen.getByText('File Input')).toBeInTheDocument()
+ })
+
+ it('should render file uploader for file-list input', async () => {
+ const promptConfig: PromptConfig = {
+ prompt_template: 'template',
+ prompt_variables: [
+ createPromptVariable({
+ key: 'fileListInput',
+ name: 'File List Input',
+ type: 'file-list',
+ }),
+ ],
+ }
+ const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalledWith({
+ fileListInput: [],
+ })
+ })
+ expect(screen.getByText('File List Input')).toBeInTheDocument()
+ })
+ })
+
+ describe('json_object input type', () => {
+ it('should render code editor for json_object input', async () => {
+ const promptConfig: PromptConfig = {
+ prompt_template: 'template',
+ prompt_variables: [
+ createPromptVariable({
+ key: 'jsonInput',
+ name: 'JSON Input',
+ type: 'json_object' as PromptVariable['type'],
+ json_schema: '{"type": "object"}',
+ }),
+ ],
+ }
+ const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalledWith({
+ jsonInput: undefined,
+ })
+ })
+ expect(screen.getByText('JSON Input')).toBeInTheDocument()
+ expect(screen.getByTestId('code-editor-mock')).toBeInTheDocument()
+ })
+
+ it('should update json_object input when code editor changes', async () => {
+ const promptConfig: PromptConfig = {
+ prompt_template: 'template',
+ prompt_variables: [
+ createPromptVariable({
+ key: 'jsonInput',
+ name: 'JSON Input',
+ type: 'json_object' as PromptVariable['type'],
+ }),
+ ],
+ }
+ const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalled()
+ })
+ onInputsChange.mockClear()
+
+ const codeEditor = screen.getByTestId('code-editor-mock')
+ fireEvent.change(codeEditor, { target: { value: '{"key": "value"}' } })
+
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalledWith({
+ jsonInput: '{"key": "value"}',
+ })
+ })
+ })
+ })
+
+ describe('hidden and optional fields', () => {
+ it('should not render hidden variables', async () => {
+ const promptConfig: PromptConfig = {
+ prompt_template: 'template',
+ prompt_variables: [
+ createPromptVariable({
+ key: 'hiddenInput',
+ name: 'Hidden Input',
+ type: 'string',
+ hide: true,
+ }),
+ createPromptVariable({
+ key: 'visibleInput',
+ name: 'Visible Input',
+ type: 'string',
+ }),
+ ],
+ }
+ const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalled()
+ })
+ expect(screen.queryByText('Hidden Input')).not.toBeInTheDocument()
+ expect(screen.getByText('Visible Input')).toBeInTheDocument()
+ })
+
+ it('should show optional label for non-required fields', async () => {
+ const promptConfig: PromptConfig = {
+ prompt_template: 'template',
+ prompt_variables: [
+ createPromptVariable({
+ key: 'optionalInput',
+ name: 'Optional Input',
+ type: 'string',
+ required: false,
+ }),
+ ],
+ }
+ const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalled()
+ })
+ expect(screen.getByText('workflow.panel.optional')).toBeInTheDocument()
+ })
+ })
+
+ describe('vision uploader', () => {
+ it('should not render vision uploader when disabled', async () => {
+ const { onInputsChange } = setup({ visionConfig: { ...baseVisionConfig, enabled: false } })
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalled()
+ })
+ expect(screen.queryByText('common.imageUploader.imageUpload')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('clear with different input types', () => {
+ it('should clear select input to undefined', async () => {
+ const promptConfig: PromptConfig = {
+ prompt_template: 'template',
+ prompt_variables: [
+ createPromptVariable({
+ key: 'selectInput',
+ name: 'Select Input',
+ type: 'select',
+ options: ['Option A', 'Option B'],
+ default: 'Option A',
+ }),
+ ],
+ }
+ const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
+ await waitFor(() => {
+ expect(onInputsChange).toHaveBeenCalled()
+ })
+ onInputsChange.mockClear()
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
+
+ expect(onInputsChange).toHaveBeenCalledWith({
+ selectInput: undefined,
+ })
+ })
+ })
+
describe('maxLength behavior', () => {
it('should not have maxLength attribute when max_length is not set', async () => {
const promptConfig: PromptConfig = {
diff --git a/web/app/components/share/utils.spec.ts b/web/app/components/share/utils.spec.ts
new file mode 100644
index 0000000000..ee2aab58eb
--- /dev/null
+++ b/web/app/components/share/utils.spec.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+import { getInitialTokenV2, isTokenV1 } from './utils'
+
+describe('utils', () => {
+ describe('isTokenV1', () => {
+ it('should return true when token has no version property', () => {
+ const token = { someKey: 'value' }
+ expect(isTokenV1(token)).toBe(true)
+ })
+
+ it('should return true when token.version is undefined', () => {
+ const token = { version: undefined }
+ expect(isTokenV1(token)).toBe(true)
+ })
+
+ it('should return true when token.version is null', () => {
+ const token = { version: null }
+ expect(isTokenV1(token)).toBe(true)
+ })
+
+ it('should return true when token.version is 0', () => {
+ const token = { version: 0 }
+ expect(isTokenV1(token)).toBe(true)
+ })
+
+ it('should return true when token.version is empty string', () => {
+ const token = { version: '' }
+ expect(isTokenV1(token)).toBe(true)
+ })
+
+ it('should return false when token has version 1', () => {
+ const token = { version: 1 }
+ expect(isTokenV1(token)).toBe(false)
+ })
+
+ it('should return false when token has version 2', () => {
+ const token = { version: 2 }
+ expect(isTokenV1(token)).toBe(false)
+ })
+
+ it('should return false when token has string version', () => {
+ const token = { version: '2' }
+ expect(isTokenV1(token)).toBe(false)
+ })
+
+ it('should handle empty object', () => {
+ const token = {}
+ expect(isTokenV1(token)).toBe(true)
+ })
+ })
+
+ describe('getInitialTokenV2', () => {
+ it('should return object with version 2', () => {
+ const token = getInitialTokenV2()
+ expect(token.version).toBe(2)
+ })
+
+ it('should return a new object each time', () => {
+ const token1 = getInitialTokenV2()
+ const token2 = getInitialTokenV2()
+ expect(token1).not.toBe(token2)
+ })
+
+ it('should return an object that can be modified without affecting future calls', () => {
+ const token1 = getInitialTokenV2()
+ token1.customField = 'test'
+ const token2 = getInitialTokenV2()
+ expect(token2.customField).toBeUndefined()
+ })
+ })
+})
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 90632b9ff4..abee200f66 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -2584,11 +2584,6 @@
"count": 2
}
},
- "app/components/share/text-generation/run-once/index.spec.tsx": {
- "ts/no-explicit-any": {
- "count": 4
- }
- },
"app/components/share/text-generation/run-once/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1