diff --git a/web/__tests__/check-i18n.test.ts b/web/__tests__/check-i18n.test.ts index de78ae997e..9e9b3d7168 100644 --- a/web/__tests__/check-i18n.test.ts +++ b/web/__tests__/check-i18n.test.ts @@ -774,7 +774,7 @@ export default translation` const endTime = Date.now() expect(keys.length).toBe(1000) - expect(endTime - startTime).toBeLessThan(1000) // Should complete in under 1 second + expect(endTime - startTime).toBeLessThan(10000) }) it('should handle multiple translation files concurrently', async () => { @@ -796,7 +796,7 @@ export default translation` const endTime = Date.now() expect(keys.length).toBe(20) // 10 files * 2 keys each - expect(endTime - startTime).toBeLessThan(500) + expect(endTime - startTime).toBeLessThan(10000) }) }) diff --git a/web/app/components/workflow-app/__tests__/index.spec.tsx b/web/app/components/workflow-app/__tests__/index.spec.tsx new file mode 100644 index 0000000000..880a62ca8d --- /dev/null +++ b/web/app/components/workflow-app/__tests__/index.spec.tsx @@ -0,0 +1,350 @@ +import type { ReactNode } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import WorkflowApp from '../index' + +const mockSetTriggerStatuses = vi.fn() +const mockSetInputs = vi.fn() +const mockSetShowInputsPanel = vi.fn() +const mockSetShowDebugAndPreviewPanel = vi.fn() +const mockWorkflowStoreSetState = vi.fn() +const mockDebouncedCancel = vi.fn() +const mockFetchRunDetail = vi.fn() +const mockInitialNodes = vi.fn() +const mockInitialEdges = vi.fn() +const mockGetWorkflowRunAndTraceUrl = vi.fn() + +let appStoreState: { + appDetail?: { + id: string + mode: string + } +} + +let workflowInitState: { + data: { + graph: { + nodes: Array> + edges: Array> + viewport: { x: number, y: number, zoom: number } + } + features: Record + } | null + isLoading: boolean + fileUploadConfigResponse: Record | null +} + +let appContextState: { + isLoadingCurrentWorkspace: boolean + currentWorkspace: { + id?: string + } +} + +let appTriggersState: { + data?: { + data: Array<{ + node_id: string + status: string + }> + } +} + +let searchParamsValue: string | null = null + +const mockWorkflowStore = { + setState: mockWorkflowStoreSetState, + getState: () => ({ + setInputs: mockSetInputs, + setShowInputsPanel: mockSetShowInputsPanel, + setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel, + debouncedSyncWorkflowDraft: { + cancel: mockDebouncedCancel, + }, + }), +} + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: typeof appStoreState) => T) => selector(appStoreState), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => mockWorkflowStore, +})) + +vi.mock('@/app/components/workflow/store/trigger-status', () => ({ + useTriggerStatusStore: () => ({ + setTriggerStatuses: mockSetTriggerStatuses, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => appContextState, +})) + +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => (key === 'replayRunId' ? searchParamsValue : null), + }), +})) + +vi.mock('@/service/log', () => ({ + fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args), +})) + +vi.mock('@/service/use-tools', () => ({ + useAppTriggers: () => appTriggersState, +})) + +vi.mock('@/app/components/workflow-app/hooks/use-workflow-init', () => ({ + useWorkflowInit: () => workflowInitState, +})) + +vi.mock('@/app/components/workflow-app/hooks/use-get-run-and-trace-url', () => ({ + useGetRunAndTraceUrl: () => ({ + getWorkflowRunAndTraceUrl: mockGetWorkflowRunAndTraceUrl, + }), +})) + +vi.mock('@/app/components/workflow/utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + initialNodes: (...args: unknown[]) => mockInitialNodes(...args), + initialEdges: (...args: unknown[]) => mockInitialEdges(...args), + } +}) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
loading
, +})) + +vi.mock('@/app/components/base/features', () => ({ + FeaturesProvider: ({ + features, + children, + }: { + features: Record + children: ReactNode + }) => ( +
+ {children} +
+ ), +})) + +vi.mock('@/app/components/workflow', () => ({ + default: ({ + nodes, + edges, + children, + }: { + nodes: Array> + edges: Array> + children: ReactNode + }) => ( +
+ {children} +
+ ), +})) + +vi.mock('@/app/components/workflow/context', () => ({ + WorkflowContextProvider: ({ + children, + }: { + injectWorkflowStoreSliceFn: unknown + children: ReactNode + }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({ + default: ({ + nodes, + edges, + viewport, + }: { + nodes: Array> + edges: Array> + viewport: Record + }) => ( +
+ ), +})) + +describe('WorkflowApp', () => { + beforeEach(() => { + vi.clearAllMocks() + appStoreState = { + appDetail: { + id: 'app-1', + mode: 'workflow', + }, + } + workflowInitState = { + data: { + graph: { + nodes: [{ id: 'raw-node' }], + edges: [{ id: 'raw-edge' }], + viewport: { x: 1, y: 2, zoom: 3 }, + }, + features: { + file_upload: { + enabled: true, + }, + }, + }, + isLoading: false, + fileUploadConfigResponse: { enabled: true }, + } + appContextState = { + isLoadingCurrentWorkspace: false, + currentWorkspace: { id: 'workspace-1' }, + } + appTriggersState = {} + searchParamsValue = null + mockFetchRunDetail.mockResolvedValue({ inputs: null }) + mockInitialNodes.mockReturnValue([{ id: 'node-1' }]) + mockInitialEdges.mockReturnValue([{ id: 'edge-1' }]) + mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '/runs/run-1' }) + }) + + it('should render the loading shell while workflow data is still loading', () => { + workflowInitState = { + data: null, + isLoading: true, + fileUploadConfigResponse: null, + } + + render() + + expect(screen.getByTestId('loading')).toBeInTheDocument() + expect(screen.queryByTestId('workflow-app-main')).not.toBeInTheDocument() + }) + + it('should render the workflow app shell and sync trigger statuses when data is ready', () => { + appTriggersState = { + data: { + data: [ + { node_id: 'trigger-enabled', status: 'enabled' }, + { node_id: 'trigger-disabled', status: 'paused' }, + ], + }, + } + + render() + + expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument() + expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-nodes', JSON.stringify([{ id: 'node-1' }])) + expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-edges', JSON.stringify([{ id: 'edge-1' }])) + expect(screen.getByTestId('workflow-app-main')).toHaveAttribute('data-viewport', JSON.stringify({ x: 1, y: 2, zoom: 3 })) + expect(screen.getByTestId('features-provider')).toBeInTheDocument() + expect(mockSetTriggerStatuses).toHaveBeenCalledWith({ + 'trigger-enabled': 'enabled', + 'trigger-disabled': 'disabled', + }) + }) + + it('should not sync trigger statuses when trigger data is unavailable', () => { + render() + + expect(screen.getByTestId('workflow-app-main')).toBeInTheDocument() + expect(mockSetTriggerStatuses).not.toHaveBeenCalled() + }) + + it('should replay workflow inputs from replayRunId and clean up workflow state on unmount', async () => { + searchParamsValue = 'run-1' + mockFetchRunDetail.mockResolvedValue({ + inputs: '{"sys.query":"hidden","foo":"bar","count":2,"flag":true,"obj":{"nested":true},"nil":null}', + }) + + const { unmount } = render() + + await waitFor(() => { + expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1') + expect(mockSetInputs).toHaveBeenCalledWith({ + foo: 'bar', + count: 2, + flag: true, + obj: '{"nested":true}', + nil: '', + }) + expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true) + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + }) + + unmount() + + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isWorkflowDataLoaded: false }) + expect(mockDebouncedCancel).toHaveBeenCalled() + }) + + it('should skip replay lookups when replayRunId is missing', () => { + render() + + expect(mockGetWorkflowRunAndTraceUrl).not.toHaveBeenCalled() + expect(mockFetchRunDetail).not.toHaveBeenCalled() + expect(mockSetInputs).not.toHaveBeenCalled() + }) + + it('should skip replay fetches when the resolved run url is empty', async () => { + searchParamsValue = 'run-1' + mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '' }) + + render() + + await waitFor(() => { + expect(mockGetWorkflowRunAndTraceUrl).toHaveBeenCalledWith('run-1') + }) + + expect(mockFetchRunDetail).not.toHaveBeenCalled() + expect(mockSetInputs).not.toHaveBeenCalled() + }) + + it('should stop replay recovery when workflow run inputs cannot be parsed', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + searchParamsValue = 'run-1' + mockFetchRunDetail.mockResolvedValue({ + inputs: '{invalid-json}', + }) + + render() + + await waitFor(() => { + expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1') + }) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to parse workflow run inputs', + expect.any(Error), + ) + expect(mockSetInputs).not.toHaveBeenCalled() + expect(mockSetShowInputsPanel).not.toHaveBeenCalled() + expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) + + it('should ignore replay inputs when they only contain sys variables', async () => { + searchParamsValue = 'run-1' + mockFetchRunDetail.mockResolvedValue({ + inputs: '{"sys.query":"hidden","sys.user_id":"u-1"}', + }) + + render() + + await waitFor(() => { + expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1') + }) + + expect(mockSetInputs).not.toHaveBeenCalled() + expect(mockSetShowInputsPanel).not.toHaveBeenCalled() + expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow-app/__tests__/utils.spec.ts b/web/app/components/workflow-app/__tests__/utils.spec.ts new file mode 100644 index 0000000000..c8a9fffeec --- /dev/null +++ b/web/app/components/workflow-app/__tests__/utils.spec.ts @@ -0,0 +1,90 @@ +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import { + buildInitialFeatures, + buildTriggerStatusMap, + coerceReplayUserInputs, +} from '../utils' + +describe('workflow-app utils', () => { + it('should map trigger statuses to enabled and disabled states', () => { + expect(buildTriggerStatusMap([ + { node_id: 'node-1', status: 'enabled' }, + { node_id: 'node-2', status: 'disabled' }, + { node_id: 'node-3', status: 'paused' }, + ])).toEqual({ + 'node-1': 'enabled', + 'node-2': 'disabled', + 'node-3': 'disabled', + }) + }) + + it('should coerce replay run inputs, omit sys keys, and stringify complex values', () => { + expect(coerceReplayUserInputs({ + 'sys.query': 'hidden', + 'query': 'hello', + 'count': 3, + 'enabled': true, + 'nullable': null, + 'metadata': { nested: true }, + })).toEqual({ + query: 'hello', + count: 3, + enabled: true, + nullable: '', + metadata: '{"nested":true}', + }) + expect(coerceReplayUserInputs('invalid')).toBeNull() + expect(coerceReplayUserInputs(null)).toBeNull() + }) + + it('should build initial features with file-upload and feature fallbacks', () => { + const result = buildInitialFeatures({ + file_upload: { + enabled: true, + allowed_file_types: [SupportUploadFileTypes.image], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: [TransferMethod.local_file], + number_limits: 2, + image: { + enabled: true, + number_limits: 5, + transfer_methods: [TransferMethod.remote_url], + }, + }, + opening_statement: 'hello', + suggested_questions: ['Q1'], + suggested_questions_after_answer: { enabled: true }, + speech_to_text: { enabled: true }, + text_to_speech: { enabled: true }, + retriever_resource: { enabled: true }, + sensitive_word_avoidance: { enabled: true }, + }, { enabled: true } as never) + + expect(result).toMatchObject({ + file: { + enabled: true, + allowed_file_types: [SupportUploadFileTypes.image], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: [TransferMethod.local_file], + number_limits: 2, + fileUploadConfig: { enabled: true }, + image: { + enabled: true, + number_limits: 5, + transfer_methods: [TransferMethod.remote_url], + }, + }, + opening: { + enabled: true, + opening_statement: 'hello', + suggested_questions: ['Q1'], + }, + suggested: { enabled: true }, + speech2text: { enabled: true }, + text2speech: { enabled: true }, + citation: { enabled: true }, + moderation: { enabled: true }, + }) + }) +}) diff --git a/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx new file mode 100644 index 0000000000..898afffb69 --- /dev/null +++ b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx @@ -0,0 +1,494 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' +import { BlockEnum } from '@/app/components/workflow/types' +import WorkflowChildren from '../workflow-children' + +type WorkflowStoreState = { + showFeaturesPanel: boolean + showImportDSLModal: boolean + setShowImportDSLModal: (show: boolean) => void + showOnboarding: boolean + setShowOnboarding: (show: boolean) => void + setHasSelectedStartNode: (selected: boolean) => void + setShouldAutoOpenStartNodeSelector: (open: boolean) => void +} + +type TriggerPluginConfig = { + plugin_id: string + provider_name: string + provider_type: string + event_name: string + event_label: string + event_description: string + output_schema: Record + paramSchemas: Array> + params: Record + subscription_id: string + plugin_unique_identifier: string + is_team_authorization: boolean + meta?: Record +} + +const mockSetShowImportDSLModal = vi.fn() +const mockSetShowOnboarding = vi.fn() +const mockSetHasSelectedStartNode = vi.fn() +const mockSetShouldAutoOpenStartNodeSelector = vi.fn() +const mockSetNodes = vi.fn() +const mockSetEdges = vi.fn() +const mockHandleSyncWorkflowDraft = vi.fn() +const mockHandleOnboardingClose = vi.fn() +const mockHandlePaneContextmenuCancel = vi.fn() +const mockHandleExportDSL = vi.fn() +const mockExportCheck = vi.fn() +const mockAutoGenerateWebhookUrl = vi.fn() + +let workflowStoreState: WorkflowStoreState +let eventSubscription: ((value: { type: string, payload: { data: Array> } }) => void) | null = null +let lastGenerateNodeInput: Record | null = null + +vi.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => ({ + setNodes: mockSetNodes, + setEdges: mockSetEdges, + }), + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: (callback: typeof eventSubscription) => { + eventSubscription = callback + }, + }, + }), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useAutoGenerateWebhookUrl: () => mockAutoGenerateWebhookUrl, + useDSL: () => ({ + exportCheck: mockExportCheck, + handleExportDSL: mockHandleExportDSL, + }), + usePanelInteractions: () => ({ + handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, + }), +})) + +vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }), +})) + +vi.mock('@/app/components/workflow/utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + generateNewNode: (args: Record) => { + lastGenerateNodeInput = args + return { + newNode: { + id: 'new-node-id', + position: args.position, + data: args.data, + }, + } + }, + } +}) + +vi.mock('@/app/components/workflow-app/hooks', () => ({ + useAvailableNodesMetaData: () => ({ + nodesMap: { + [BlockEnum.Start]: { + defaultValue: { + title: 'Start Title', + desc: 'Start description', + config: { + image: false, + }, + }, + }, + [BlockEnum.TriggerPlugin]: { + defaultValue: { + title: 'Plugin title', + desc: 'Plugin description', + config: { + baseConfig: 'base', + }, + }, + }, + }, + }), +})) + +vi.mock('@/app/components/workflow-app/hooks/use-auto-onboarding', () => ({ + useAutoOnboarding: () => ({ + handleOnboardingClose: mockHandleOnboardingClose, + }), +})) + +vi.mock('@/app/components/workflow/plugin-dependency', () => ({ + default: () =>
plugin-dependency
, +})) + +vi.mock('@/app/components/workflow-app/components/workflow-header', () => ({ + default: () =>
workflow-header
, +})) + +vi.mock('@/app/components/workflow-app/components/workflow-panel', () => ({ + default: () =>
workflow-panel
, +})) + +vi.mock('@/next/dynamic', async () => { + const ReactModule = await import('react') + + return { + default: ( + loader: () => Promise<{ default: React.ComponentType> }>, + ) => { + const DynamicComponent = (props: Record) => { + const [Loaded, setLoaded] = ReactModule.useState> | null>(null) + + ReactModule.useEffect(() => { + let mounted = true + loader().then((mod) => { + if (mounted) + setLoaded(() => mod.default) + }) + return () => { + mounted = false + } + }, []) + + return Loaded ? : null + } + + return DynamicComponent + }, + } +}) + +vi.mock('@/app/components/workflow/features', () => ({ + default: () =>
features
, +})) + +vi.mock('@/app/components/workflow/update-dsl-modal', () => ({ + default: ({ + onCancel, + onBackup, + onImport, + }: { + onCancel: () => void + onBackup: () => void + onImport: () => void + }) => ( +
+ + + +
+ ), +})) + +vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ + default: ({ + envList, + onConfirm, + onClose, + }: { + envList: Array> + onConfirm: () => void + onClose: () => void + }) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/workflow-app/components/workflow-onboarding-modal', () => ({ + default: ({ + onClose, + onSelectStartNode, + }: { + isShow: boolean + onClose: () => void + onSelectStartNode: (nodeType: BlockEnum, config?: TriggerPluginConfig) => void + }) => ( +
+ + + + + +
+ ), +})) + +describe('WorkflowChildren', () => { + beforeEach(() => { + vi.clearAllMocks() + workflowStoreState = { + showFeaturesPanel: false, + showImportDSLModal: false, + setShowImportDSLModal: mockSetShowImportDSLModal, + showOnboarding: false, + setShowOnboarding: mockSetShowOnboarding, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + } + eventSubscription = null + lastGenerateNodeInput = null + mockHandleSyncWorkflowDraft.mockImplementation((_force?: boolean, _notRefresh?: boolean, callback?: { onSuccess?: () => void }) => { + callback?.onSuccess?.() + }) + }) + + it('should render feature panel, import modal actions, and default workflow chrome', async () => { + const user = userEvent.setup() + workflowStoreState = { + ...workflowStoreState, + showFeaturesPanel: true, + showImportDSLModal: true, + } + + render() + + expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument() + expect(screen.getByTestId('workflow-header')).toBeInTheDocument() + expect(screen.getByTestId('workflow-panel')).toBeInTheDocument() + expect(await screen.findByTestId('workflow-features')).toBeInTheDocument() + expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /cancel-import-dsl/i })) + await user.click(screen.getByRole('button', { name: /backup-dsl/i })) + await user.click(screen.getByRole('button', { name: /^import-dsl$/i })) + + expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false) + expect(mockExportCheck).toHaveBeenCalled() + expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled() + }) + + it('should react to DSL export check events by showing the confirm modal and closing it', async () => { + const user = userEvent.setup() + + render() + + await act(async () => { + eventSubscription?.({ + type: DSL_EXPORT_CHECK, + payload: { + data: [{ id: 'env-1' }, { id: 'env-2' }], + }, + }) + }) + + expect(await screen.findByTestId('dsl-export-confirm-modal')).toHaveAttribute('data-env-count', '2') + + await user.click(screen.getByRole('button', { name: /confirm-export-dsl/i })) + await user.click(screen.getByRole('button', { name: /close-export-dsl/i })) + + expect(mockHandleExportDSL).toHaveBeenCalled() + expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument() + }) + + it('should ignore unrelated workflow events when listening for DSL export checks', async () => { + render() + + await act(async () => { + eventSubscription?.({ + type: 'UNRELATED_EVENT', + payload: { + data: [{ id: 'env-1' }], + }, + }) + }) + + expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument() + }) + + it('should close onboarding through the onboarding hook callback', async () => { + const user = userEvent.setup() + workflowStoreState = { + ...workflowStoreState, + showOnboarding: true, + } + + render() + + expect(await screen.findByTestId('workflow-onboarding-modal')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /close-onboarding/i })) + + expect(mockHandleOnboardingClose).toHaveBeenCalled() + }) + + it('should create a start node, sync draft, and auto-generate webhook url after selecting a start node', async () => { + const user = userEvent.setup() + workflowStoreState = { + ...workflowStoreState, + showOnboarding: true, + } + + render() + + await user.click(await screen.findByRole('button', { name: /^select-start-node$/i })) + + expect(lastGenerateNodeInput).toMatchObject({ + data: { + title: 'Start Title', + desc: 'Start description', + config: { + image: false, + }, + }, + }) + expect(mockSetNodes).toHaveBeenCalledWith([expect.objectContaining({ id: 'new-node-id' })]) + expect(mockSetEdges).toHaveBeenCalledWith([]) + expect(mockSetShowOnboarding).toHaveBeenCalledWith(false) + expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true) + expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, false, expect.any(Object)) + expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('new-node-id') + }) + + it('should merge non-trigger start node config directly into the default node data', async () => { + const user = userEvent.setup() + workflowStoreState = { + ...workflowStoreState, + showOnboarding: true, + } + + render() + + await user.click(await screen.findByRole('button', { name: /select-start-node-with-config/i })) + + expect(lastGenerateNodeInput).toMatchObject({ + data: { + title: 'Configured Start Title', + desc: 'Configured Start Description', + config: { + image: true, + custom: 'config', + }, + extra: 'field', + }, + }) + }) + + it('should merge trigger plugin defaults and config before creating the node', async () => { + const user = userEvent.setup() + workflowStoreState = { + ...workflowStoreState, + showOnboarding: true, + } + + render() + + await user.click(await screen.findByRole('button', { name: /^select-trigger-plugin$/i })) + + expect(lastGenerateNodeInput).toMatchObject({ + data: { + plugin_id: 'plugin-id', + provider_id: 'provider-name', + provider_name: 'provider-name', + provider_type: 'tool', + event_name: 'event-name', + event_label: 'Event Label', + event_description: 'Event Description', + title: 'Event Label', + desc: 'Event Description', + output_schema: { output: true }, + parameters_schema: [{ name: 'api_key' }], + config: { + baseConfig: 'base', + token: 'abc', + }, + subscription_id: 'subscription-id', + plugin_unique_identifier: 'plugin-unique', + is_team_authorization: true, + meta: { source: 'plugin' }, + }, + }) + }) + + it('should fall back to plugin default title and description when trigger labels are missing', async () => { + const user = userEvent.setup() + workflowStoreState = { + ...workflowStoreState, + showOnboarding: true, + } + + render() + + await user.click(await screen.findByRole('button', { name: /select-trigger-plugin-fallback/i })) + + expect(lastGenerateNodeInput).toMatchObject({ + data: { + title: 'Plugin title', + desc: 'Plugin description', + parameters_schema: [], + config: { + baseConfig: 'base', + }, + }, + }) + }) +}) diff --git a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx new file mode 100644 index 0000000000..250b87069f --- /dev/null +++ b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx @@ -0,0 +1,277 @@ +import type { ReactNode } from 'react' +import type { WorkflowProps } from '@/app/components/workflow' +import { fireEvent, render, screen } from '@testing-library/react' +import WorkflowMain from '../workflow-main' + +const mockSetFeatures = vi.fn() +const mockSetConversationVariables = vi.fn() +const mockSetEnvironmentVariables = vi.fn() + +const hookFns = { + doSyncWorkflowDraft: vi.fn(), + syncWorkflowDraftWhenPageClose: vi.fn(), + handleRefreshWorkflowDraft: vi.fn(), + handleBackupDraft: vi.fn(), + handleLoadBackupDraft: vi.fn(), + handleRestoreFromPublishedWorkflow: vi.fn(), + handleRun: vi.fn(), + handleStopRun: vi.fn(), + handleStartWorkflowRun: vi.fn(), + handleWorkflowStartRunInChatflow: vi.fn(), + handleWorkflowStartRunInWorkflow: vi.fn(), + handleWorkflowTriggerScheduleRunInWorkflow: vi.fn(), + handleWorkflowTriggerWebhookRunInWorkflow: vi.fn(), + handleWorkflowTriggerPluginRunInWorkflow: vi.fn(), + handleWorkflowRunAllTriggersInWorkflow: vi.fn(), + getWorkflowRunAndTraceUrl: vi.fn(), + exportCheck: vi.fn(), + handleExportDSL: vi.fn(), + fetchInspectVars: vi.fn(), + 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(), +} + +let capturedContextProps: Record | null = null + +type MockWorkflowWithInnerContextProps = Pick & { + hooksStore?: Record + children?: ReactNode +} + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeaturesStore: () => ({ + getState: () => ({ + setFeatures: mockSetFeatures, + }), + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + setConversationVariables: mockSetConversationVariables, + setEnvironmentVariables: mockSetEnvironmentVariables, + }), + }), +})) + +vi.mock('@/app/components/workflow', () => ({ + WorkflowWithInnerContext: ({ + nodes, + edges, + viewport, + onWorkflowDataUpdate, + hooksStore, + children, + }: MockWorkflowWithInnerContextProps) => { + capturedContextProps = { + nodes, + edges, + viewport, + hooksStore, + } + return ( +
+ + + + {children} +
+ ) + }, +})) + +vi.mock('@/app/components/workflow-app/hooks', () => ({ + useAvailableNodesMetaData: () => ({ nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } }), + useConfigsMap: () => ({ flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } }), + useDSL: () => ({ exportCheck: hookFns.exportCheck, handleExportDSL: hookFns.handleExportDSL }), + useGetRunAndTraceUrl: () => ({ getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl }), + useInspectVarsCrud: () => ({ + hasNodeInspectVars: hookFns.hasNodeInspectVars, + hasSetInspectVar: hookFns.hasSetInspectVar, + fetchInspectVarValue: hookFns.fetchInspectVarValue, + editInspectVarValue: hookFns.editInspectVarValue, + renameInspectVarName: hookFns.renameInspectVarName, + appendNodeInspectVars: hookFns.appendNodeInspectVars, + deleteInspectVar: hookFns.deleteInspectVar, + deleteNodeInspectorVars: hookFns.deleteNodeInspectorVars, + deleteAllInspectorVars: hookFns.deleteAllInspectorVars, + isInspectVarEdited: hookFns.isInspectVarEdited, + resetToLastRunVar: hookFns.resetToLastRunVar, + invalidateSysVarValues: hookFns.invalidateSysVarValues, + resetConversationVar: hookFns.resetConversationVar, + invalidateConversationVarValues: hookFns.invalidateConversationVarValues, + }), + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose, + }), + useSetWorkflowVarsWithValue: () => ({ + fetchInspectVars: hookFns.fetchInspectVars, + }), + useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft }), + useWorkflowRun: () => ({ + handleBackupDraft: hookFns.handleBackupDraft, + handleLoadBackupDraft: hookFns.handleLoadBackupDraft, + handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow, + handleRun: hookFns.handleRun, + handleStopRun: hookFns.handleStopRun, + }), + useWorkflowStartRun: () => ({ + handleStartWorkflowRun: hookFns.handleStartWorkflowRun, + handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow, + handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow, + }), +})) + +vi.mock('../workflow-children', () => ({ + default: () =>
workflow-children
, +})) + +describe('WorkflowMain', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedContextProps = null + }) + + it('should render the inner workflow context with children and forwarded graph props', () => { + const nodes = [{ id: 'node-1' }] + const edges = [{ id: 'edge-1' }] + const viewport = { x: 1, y: 2, zoom: 1.5 } + + render( + , + ) + + expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument() + expect(screen.getByTestId('workflow-children')).toBeInTheDocument() + expect(capturedContextProps).toMatchObject({ + nodes, + edges, + viewport, + }) + }) + + it('should update features and workflow variables when workflow data changes', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /update-workflow-data/i })) + + expect(mockSetFeatures).toHaveBeenCalledWith({ file: { enabled: true } }) + expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }]) + expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-1' }]) + }) + + it('should only update the workflow store slices present in the payload', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /update-conversation-only/i })) + + expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-only' }]) + expect(mockSetFeatures).not.toHaveBeenCalled() + expect(mockSetEnvironmentVariables).not.toHaveBeenCalled() + }) + + it('should ignore empty workflow data updates', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /update-empty-payload/i })) + + expect(mockSetFeatures).not.toHaveBeenCalled() + expect(mockSetConversationVariables).not.toHaveBeenCalled() + expect(mockSetEnvironmentVariables).not.toHaveBeenCalled() + }) + + it('should expose the composed workflow action hooks through hooksStore', () => { + render( + , + ) + + expect(capturedContextProps?.hooksStore).toMatchObject({ + syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose, + doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft, + handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft, + handleBackupDraft: hookFns.handleBackupDraft, + handleLoadBackupDraft: hookFns.handleLoadBackupDraft, + handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow, + handleRun: hookFns.handleRun, + handleStopRun: hookFns.handleStopRun, + handleStartWorkflowRun: hookFns.handleStartWorkflowRun, + handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow, + handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow, + availableNodesMetaData: { nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } }, + getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl, + exportCheck: hookFns.exportCheck, + handleExportDSL: hookFns.handleExportDSL, + fetchInspectVars: hookFns.fetchInspectVars, + configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } }, + }) + }) +}) diff --git a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx new file mode 100644 index 0000000000..e24b4fd8b2 --- /dev/null +++ b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx @@ -0,0 +1,214 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import WorkflowPanel from '../workflow-panel' + +type AppStoreState = { + appDetail?: { + id?: string + workflow?: { + id?: string + } + } + currentLogItem?: { id: string } + setCurrentLogItem: (item?: { id: string }) => void + showMessageLogModal: boolean + setShowMessageLogModal: (show: boolean) => void + currentLogModalActiveTab?: string +} + +type WorkflowStoreState = { + historyWorkflowData?: Record + showDebugAndPreviewPanel: boolean + showChatVariablePanel: boolean + showGlobalVariablePanel: boolean +} + +const mockUseIsChatMode = vi.fn() +const mockSetCurrentLogItem = vi.fn() +const mockSetShowMessageLogModal = vi.fn() + +let appStoreState: AppStoreState +let workflowStoreState: WorkflowStoreState + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: AppStoreState) => T) => selector(appStoreState), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState), +})) + +vi.mock('@/app/components/workflow/panel', () => ({ + default: ({ + components, + versionHistoryPanelProps, + }: { + components?: { + left?: ReactNode + right?: ReactNode + } + versionHistoryPanelProps?: { + getVersionListUrl: string + deleteVersionUrl: (versionId: string) => string + restoreVersionUrl: (versionId: string) => string + updateVersionUrl: (versionId: string) => string + latestVersionId?: string + } + }) => ( +
+
{components?.left}
+
{components?.right}
+
+ ), +})) + +vi.mock('@/next/dynamic', () => ({ + default: (loader: () => Promise<{ default: React.ComponentType> }>) => { + const LazyComp = React.lazy(loader) + return function DynamicWrapper(props: Record) { + return React.createElement( + React.Suspense, + { fallback: null }, + React.createElement(LazyComp, props), + ) + } + }, +})) + +vi.mock('@/app/components/base/message-log-modal', () => ({ + default: ({ + currentLogItem, + defaultTab, + onCancel, + }: { + currentLogItem?: { id: string } + defaultTab?: string + onCancel: () => void + }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/workflow/panel/record', () => ({ + default: () =>
record
, +})) + +vi.mock('@/app/components/workflow/panel/chat-record', () => ({ + default: () =>
chat-record
, +})) + +vi.mock('@/app/components/workflow/panel/debug-and-preview', () => ({ + default: () =>
debug
, +})) + +vi.mock('@/app/components/workflow/panel/workflow-preview', () => ({ + default: () =>
preview
, +})) + +vi.mock('@/app/components/workflow/panel/chat-variable-panel', () => ({ + default: () =>
chat-variable
, +})) + +vi.mock('@/app/components/workflow/panel/global-variable-panel', () => ({ + default: () =>
global-variable
, +})) + +vi.mock('@/app/components/workflow-app/hooks', () => ({ + useIsChatMode: () => mockUseIsChatMode(), +})) + +describe('WorkflowPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + appStoreState = { + appDetail: { + id: 'app-123', + workflow: { + id: 'workflow-version-id', + }, + }, + currentLogItem: { id: 'log-1' }, + setCurrentLogItem: mockSetCurrentLogItem, + showMessageLogModal: false, + setShowMessageLogModal: mockSetShowMessageLogModal, + currentLogModalActiveTab: 'detail', + } + workflowStoreState = { + historyWorkflowData: undefined, + showDebugAndPreviewPanel: false, + showChatVariablePanel: false, + showGlobalVariablePanel: false, + } + mockUseIsChatMode.mockReturnValue(false) + }) + + it('should configure workflow version history urls and latest version id for the panel shell', async () => { + render() + + const panel = await screen.findByTestId('panel') + expect(panel).toHaveAttribute('data-version-list-url', '/apps/app-123/workflows') + expect(panel).toHaveAttribute('data-delete-version-url', '/apps/app-123/workflows/version-1') + expect(panel).toHaveAttribute('data-restore-version-url', '/apps/app-123/workflows/version-1/restore') + expect(panel).toHaveAttribute('data-update-version-url', '/apps/app-123/workflows/version-1') + expect(panel).toHaveAttribute('data-latest-version-id', 'workflow-version-id') + }) + + it('should render and close the message log modal from the left panel slot', async () => { + const user = userEvent.setup() + appStoreState = { + ...appStoreState, + showMessageLogModal: true, + } + + render() + + expect(await screen.findByTestId('message-log-modal')).toHaveAttribute('data-current-log-id', 'log-1') + expect(screen.getByTestId('message-log-modal')).toHaveAttribute('data-default-tab', 'detail') + + await user.click(screen.getByRole('button', { name: /close-message-log/i })) + + expect(mockSetCurrentLogItem).toHaveBeenCalledWith() + expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) + }) + + it('should switch right-side workflow panels based on chat mode and workflow state', async () => { + workflowStoreState = { + historyWorkflowData: { id: 'history-1' }, + showDebugAndPreviewPanel: true, + showChatVariablePanel: true, + showGlobalVariablePanel: true, + } + mockUseIsChatMode.mockReturnValue(true) + + const { unmount } = render() + + expect(await screen.findByTestId('chat-record-panel')).toBeInTheDocument() + expect(screen.getByTestId('debug-and-preview-panel')).toBeInTheDocument() + expect(screen.getByTestId('chat-variable-panel')).toBeInTheDocument() + expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument() + expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument() + expect(screen.queryByTestId('workflow-preview-panel')).not.toBeInTheDocument() + + unmount() + mockUseIsChatMode.mockReturnValue(false) + render() + + expect(await screen.findByTestId('record-panel')).toBeInTheDocument() + expect(screen.getByTestId('workflow-preview-panel')).toBeInTheDocument() + expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument() + expect(screen.queryByTestId('chat-record-panel')).not.toBeInTheDocument() + expect(screen.queryByTestId('debug-and-preview-panel')).not.toBeInTheDocument() + expect(screen.queryByTestId('chat-variable-panel')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index 2318a1c7bc..f29fa14169 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -149,6 +149,7 @@ const createProviderContext = ({ const renderWithToast = (ui: ReactElement) => { return render( + // eslint-disable-next-line react/no-context-provider {ui} , @@ -445,6 +446,27 @@ describe('FeaturesTrigger', () => { }) }) + it('should skip success side effects when publish mutation returns no workflow version', async () => { + // Arrange + const user = userEvent.setup() + mockPublishWorkflow.mockResolvedValue(null) + renderWithToast() + + // Act + await user.click(screen.getByRole('button', { name: 'publisher-publish' })) + + // Assert + await waitFor(() => { + expect(mockPublishWorkflow).toHaveBeenCalled() + }) + expect(mockNotify).not.toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' }) + expect(mockUpdatePublishedWorkflow).not.toHaveBeenCalled() + expect(mockInvalidateAppTriggers).not.toHaveBeenCalled() + expect(mockSetPublishedAt).not.toHaveBeenCalled() + expect(mockSetLastPublishedHasUserInput).not.toHaveBeenCalled() + expect(mockResetWorkflowVersionHistory).not.toHaveBeenCalled() + }) + it('should log error when app detail refresh fails after publish', async () => { // Arrange const user = userEvent.setup() diff --git a/web/app/components/workflow-app/hooks/__tests__/index.spec.ts b/web/app/components/workflow-app/hooks/__tests__/index.spec.ts new file mode 100644 index 0000000000..e59409d9f6 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/index.spec.ts @@ -0,0 +1,18 @@ +import * as hooks from '../index' + +describe('workflow-app hooks index', () => { + it('should re-export workflow-app hooks', () => { + expect(hooks.useAvailableNodesMetaData).toBeTypeOf('function') + expect(hooks.useConfigsMap).toBeTypeOf('function') + expect(hooks.useDSL).toBeTypeOf('function') + expect(hooks.useGetRunAndTraceUrl).toBeTypeOf('function') + expect(hooks.useInspectVarsCrud).toBeTypeOf('function') + expect(hooks.useIsChatMode).toBeTypeOf('function') + expect(hooks.useNodesSyncDraft).toBeTypeOf('function') + expect(hooks.useWorkflowInit).toBeTypeOf('function') + expect(hooks.useWorkflowRefreshDraft).toBeTypeOf('function') + expect(hooks.useWorkflowRun).toBeTypeOf('function') + expect(hooks.useWorkflowStartRun).toBeTypeOf('function') + expect(hooks.useWorkflowTemplate).toBeTypeOf('function') + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts new file mode 100644 index 0000000000..0716d71dce --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts @@ -0,0 +1,206 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' +import { useDSL } from '../use-DSL' + +const mockNotify = vi.fn() +const mockEmit = vi.fn() +const mockDoSyncWorkflowDraft = vi.fn() +const mockExportAppConfig = vi.fn() +const mockFetchWorkflowDraft = vi.fn() +const mockDownloadBlob = vi.fn() + +let appStoreState: { + appDetail?: { + id: string + name: string + } +} + +vi.mock('@/app/components/base/toast/context', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + emit: mockEmit, + }, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: typeof appStoreState) => T) => selector(appStoreState), +})) + +vi.mock('../use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: mockDoSyncWorkflowDraft, + }), +})) + +vi.mock('@/service/apps', () => ({ + exportAppConfig: (...args: unknown[]) => mockExportAppConfig(...args), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +const createDeferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +describe('useDSL', () => { + beforeEach(() => { + vi.clearAllMocks() + appStoreState = { + appDetail: { + id: 'app-1', + name: 'Workflow App', + }, + } + mockDoSyncWorkflowDraft.mockResolvedValue(undefined) + mockExportAppConfig.mockResolvedValue({ data: 'yaml-content' }) + mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] }) + }) + + it('should export workflow dsl and download the yaml blob when no secret env is present', async () => { + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft') + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockExportAppConfig).toHaveBeenCalledWith({ + appID: 'app-1', + include: false, + workflowID: undefined, + }) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.any(Blob), + fileName: 'Workflow App.yml', + })) + }) + + it('should forward include and workflow id arguments when exporting dsl directly', async () => { + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL(true, 'workflow-1') + }) + + expect(mockExportAppConfig).toHaveBeenCalledWith({ + appID: 'app-1', + include: true, + workflowID: 'workflow-1', + }) + }) + + it('should emit DSL_EXPORT_CHECK when secret environment variables exist', async () => { + const secretVars = [{ id: 'env-1', value_type: 'secret', value: 'secret-token' }] + mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars }) + + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + expect(mockEmit).toHaveBeenCalledWith({ + type: DSL_EXPORT_CHECK, + payload: { + data: secretVars, + }, + }) + expect(mockExportAppConfig).not.toHaveBeenCalled() + }) + + it('should return early when app detail is unavailable', async () => { + appStoreState = {} + + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + await result.current.handleExportDSL() + }) + + expect(mockFetchWorkflowDraft).not.toHaveBeenCalled() + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockExportAppConfig).not.toHaveBeenCalled() + expect(mockEmit).not.toHaveBeenCalled() + }) + + it('should notify when export fails', async () => { + mockExportAppConfig.mockRejectedValue(new Error('export failed')) + + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.handleExportDSL() + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'app.exportFailed', + }) + }) + }) + + it('should notify when exportCheck cannot load the workflow draft', async () => { + mockFetchWorkflowDraft.mockRejectedValue(new Error('draft fetch failed')) + + const { result } = renderHook(() => useDSL()) + + await act(async () => { + await result.current.exportCheck() + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'app.exportFailed', + }) + }) + expect(mockExportAppConfig).not.toHaveBeenCalled() + }) + + it('should ignore repeated export attempts while an export is already in progress', async () => { + const deferred = createDeferred<{ data: string }>() + mockExportAppConfig.mockReturnValue(deferred.promise) + + const { result } = renderHook(() => useDSL()) + let firstExportPromise!: Promise + + act(() => { + firstExportPromise = result.current.handleExportDSL() + }) + + await waitFor(() => { + expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) + expect(mockExportAppConfig).toHaveBeenCalledTimes(1) + }) + + act(() => { + void result.current.handleExportDSL() + }) + + expect(mockExportAppConfig).toHaveBeenCalledTimes(1) + + await act(async () => { + deferred.resolve({ data: 'yaml-content' }) + await firstExportPromise + }) + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-auto-onboarding.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-auto-onboarding.spec.ts new file mode 100644 index 0000000000..f811902618 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-auto-onboarding.spec.ts @@ -0,0 +1,118 @@ +import { act, renderHook } from '@testing-library/react' +import { useAutoOnboarding } from '../use-auto-onboarding' + +const mockGetNodes = vi.fn() +const mockWorkflowStore = { + getState: vi.fn(), +} + +const mockSetShowOnboarding = vi.fn() +const mockSetHasShownOnboarding = vi.fn() +const mockSetShouldAutoOpenStartNodeSelector = vi.fn() +const mockSetHasSelectedStartNode = vi.fn() + +vi.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => ({ + getNodes: mockGetNodes, + }), + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => mockWorkflowStore, +})) + +describe('useAutoOnboarding', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockGetNodes.mockReturnValue([]) + mockWorkflowStore.getState.mockReturnValue({ + showOnboarding: false, + hasShownOnboarding: false, + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should open onboarding after the delayed empty-canvas check on mount', () => { + renderHook(() => useAutoOnboarding()) + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(mockSetShowOnboarding).toHaveBeenCalledWith(true) + expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true) + expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true) + }) + + it('should skip auto onboarding when it is already visible or the workflow is not initial', () => { + mockWorkflowStore.getState.mockReturnValue({ + showOnboarding: true, + hasShownOnboarding: false, + notInitialWorkflow: true, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + }) + + renderHook(() => useAutoOnboarding()) + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(mockSetShowOnboarding).not.toHaveBeenCalled() + expect(mockSetHasShownOnboarding).not.toHaveBeenCalled() + expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled() + }) + + it('should close onboarding and reset selected start node state when one was chosen', () => { + mockWorkflowStore.getState.mockReturnValue({ + showOnboarding: false, + hasShownOnboarding: true, + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + hasSelectedStartNode: true, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + }) + + const { result } = renderHook(() => useAutoOnboarding()) + + act(() => { + result.current.handleOnboardingClose() + }) + + expect(mockSetShowOnboarding).toHaveBeenCalledWith(false) + expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true) + expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(false) + expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled() + }) + + it('should close onboarding and disable auto-open when no start node was selected', () => { + const { result } = renderHook(() => useAutoOnboarding()) + + act(() => { + result.current.handleOnboardingClose() + }) + + expect(mockSetShowOnboarding).toHaveBeenCalledWith(false) + expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true) + expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false) + expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts new file mode 100644 index 0000000000..c92e438cb3 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts @@ -0,0 +1,49 @@ +import { renderHook } from '@testing-library/react' +import { BlockEnum } from '@/app/components/workflow/types' +import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data' + +const mockUseIsChatMode = vi.fn() + +vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({ + useIsChatMode: () => mockUseIsChatMode(), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `/docs${path}`, +})) + +describe('useAvailableNodesMetaData', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should include chat-specific nodes and make the start node undeletable in chat mode', () => { + mockUseIsChatMode.mockReturnValue(true) + + const { result } = renderHook(() => useAvailableNodesMetaData()) + + expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(true) + expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeDefined() + expect(result.current.nodesMap?.[BlockEnum.End]).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap?.[BlockEnum.VariableAggregator]) + expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toContain('/docs/use-dify/nodes/') + }) + + it('should include workflow-specific trigger and end nodes outside chat mode', () => { + mockUseIsChatMode.mockReturnValue(false) + + const { result } = renderHook(() => useAvailableNodesMetaData()) + + expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(false) + expect(result.current.nodesMap?.[BlockEnum.End]).toBeDefined() + expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeDefined() + expect(result.current.nodesMap?.[BlockEnum.TriggerSchedule]).toBeDefined() + expect(result.current.nodesMap?.[BlockEnum.TriggerPlugin]).toBeDefined() + expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.Start]?.defaultValue).toMatchObject({ + type: BlockEnum.Start, + title: 'workflow.blocks.start', + }) + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-configs-map.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-configs-map.spec.ts new file mode 100644 index 0000000000..34e4504449 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-configs-map.spec.ts @@ -0,0 +1,40 @@ +import { renderHook } from '@testing-library/react' +import { FlowType } from '@/types/common' +import { useConfigsMap } from '../use-configs-map' + +const mockUseFeatures = vi.fn() + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: { features: { file: Record } }) => unknown) => mockUseFeatures(selector), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { appId: string }) => T) => selector({ appId: 'app-1' }), +})) + +describe('useConfigsMap', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseFeatures.mockImplementation((selector: (state: { features: { file: Record } }) => unknown) => selector({ + features: { + file: { + enabled: true, + number_limits: 3, + }, + }, + })) + }) + + it('should map workflow app id and feature file settings into inspect-var configs', () => { + const { result } = renderHook(() => useConfigsMap()) + + expect(result.current).toEqual({ + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: { + enabled: true, + number_limits: 3, + }, + }) + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-get-run-and-trace-url.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-get-run-and-trace-url.spec.ts new file mode 100644 index 0000000000..196ad8c915 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-get-run-and-trace-url.spec.ts @@ -0,0 +1,28 @@ +import { renderHook } from '@testing-library/react' +import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url' + +const mockWorkflowStore = { + getState: vi.fn(), +} + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => mockWorkflowStore, +})) + +describe('useGetRunAndTraceUrl', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWorkflowStore.getState.mockReturnValue({ + appId: 'app-123', + }) + }) + + it('should build workflow run and trace urls from the current app id', () => { + const { result } = renderHook(() => useGetRunAndTraceUrl()) + + expect(result.current.getWorkflowRunAndTraceUrl('run-1')).toEqual({ + runUrl: '/apps/app-123/workflow-runs/run-1', + traceUrl: '/apps/app-123/workflow-runs/run-1/node-executions', + }) + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-inspect-vars-crud.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-inspect-vars-crud.spec.ts new file mode 100644 index 0000000000..7bad81e5f7 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-inspect-vars-crud.spec.ts @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react' +import { useInspectVarsCrud } from '../use-inspect-vars-crud' + +const mockUseInspectVarsCrudCommon = vi.fn() +const mockUseConfigsMap = vi.fn() + +vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud-common', () => ({ + useInspectVarsCrudCommon: (...args: unknown[]) => mockUseInspectVarsCrudCommon(...args), +})) + +vi.mock('@/app/components/workflow-app/hooks/use-configs-map', () => ({ + useConfigsMap: () => mockUseConfigsMap(), +})) + +describe('useInspectVarsCrud', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseConfigsMap.mockReturnValue({ + flowId: 'app-1', + flowType: 'app-flow', + fileSettings: { enabled: true }, + }) + mockUseInspectVarsCrudCommon.mockReturnValue({ + fetchInspectVarValue: vi.fn(), + editInspectVarValue: vi.fn(), + deleteInspectVar: vi.fn(), + }) + }) + + it('should call the shared inspect vars hook with workflow-app configs and return its api', () => { + const { result } = renderHook(() => useInspectVarsCrud()) + + expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith({ + flowId: 'app-1', + flowType: 'app-flow', + fileSettings: { enabled: true }, + }) + expect(result.current).toEqual({ + fetchInspectVarValue: expect.any(Function), + editInspectVarValue: expect.any(Function), + deleteInspectVar: expect.any(Function), + }) + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts index fd808affc3..c9fa535d51 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -4,42 +4,57 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useNodesSyncDraft } from '../use-nodes-sync-draft' const mockGetNodes = vi.fn() +const mockPostWithKeepalive = vi.fn() +const mockSetSyncWorkflowDraftHash = vi.fn() +const mockSetDraftUpdatedAt = vi.fn() +const mockGetNodesReadOnly = vi.fn() + +let reactFlowState: { + getNodes: typeof mockGetNodes + edges: Array> + transform: [number, number, number] +} + +let workflowStoreState: { + appId: string + isWorkflowDataLoaded: boolean + syncWorkflowDraftHash: string | null + environmentVariables: Array> + conversationVariables: Array> + setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash + setDraftUpdatedAt: typeof mockSetDraftUpdatedAt +} + +let featuresState: { + features: { + opening: { enabled: boolean, opening_statement: string, suggested_questions: string[] } + suggested: Record + text2speech: Record + speech2text: Record + citation: Record + moderation: Record + file: Record + } +} + vi.mock('reactflow', () => ({ - useStoreApi: () => ({ getState: () => ({ getNodes: mockGetNodes, edges: [], transform: [0, 0, 1] }) }), + useStoreApi: () => ({ getState: () => reactFlowState }), })) vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => ({ - getState: () => ({ - appId: 'app-1', - isWorkflowDataLoaded: true, - syncWorkflowDraftHash: 'hash-123', - environmentVariables: [], - conversationVariables: [], - setSyncWorkflowDraftHash: vi.fn(), - setDraftUpdatedAt: vi.fn(), - }), + getState: () => workflowStoreState, }), })) vi.mock('@/app/components/base/features/hooks', () => ({ useFeaturesStore: () => ({ - getState: () => ({ - features: { - opening: { enabled: false, opening_statement: '', suggested_questions: [] }, - suggested: {}, - text2speech: {}, - speech2text: {}, - citation: {}, - moderation: {}, - file: {}, - }, - }), + getState: () => featuresState, }), })) vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({ - useNodesReadOnly: () => ({ getNodesReadOnly: () => false }), + useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly }), })) vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({ @@ -55,7 +70,7 @@ vi.mock('@/service/workflow', () => ({ syncWorkflowDraft: (p: unknown) => mockSyncWorkflowDraft(p), })) -vi.mock('@/service/fetch', () => ({ postWithKeepalive: vi.fn() })) +vi.mock('@/service/fetch', () => ({ postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args) })) vi.mock('@/config', () => ({ API_PREFIX: '/api' })) const mockHandleRefreshWorkflowDraft = vi.fn() @@ -66,6 +81,32 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => { beforeEach(() => { vi.clearAllMocks() + reactFlowState = { + getNodes: mockGetNodes, + edges: [], + transform: [0, 0, 1], + } + workflowStoreState = { + appId: 'app-1', + isWorkflowDataLoaded: true, + syncWorkflowDraftHash: 'hash-123', + environmentVariables: [], + conversationVariables: [], + setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash, + setDraftUpdatedAt: mockSetDraftUpdatedAt, + } + featuresState = { + features: { + opening: { enabled: false, opening_statement: '', suggested_questions: [] }, + suggested: {}, + text2speech: {}, + speech2text: {}, + citation: {}, + moderation: {}, + file: {}, + }, + } + mockGetNodesReadOnly.mockReturnValue(false) mockGetNodes.mockReturnValue([{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start' } }]) mockSyncWorkflowDraft.mockResolvedValue({ hash: 'new', updated_at: 1 }) }) @@ -122,4 +163,102 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => }), })) }) + + it('should strip temp entities and private data, use the latest hash, and invoke success callbacks', async () => { + reactFlowState = { + ...reactFlowState, + edges: [ + { id: 'edge-1', source: 'n1', target: 'n2', data: { _isTemp: false, _private: 'drop', stable: 'keep' } }, + { id: 'temp-edge', source: 'n2', target: 'n3', data: { _isTemp: true } }, + ], + transform: [10, 20, 1.5], + } + mockGetNodes.mockReturnValue([ + { id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', _tempField: 'drop', label: 'Start' } }, + { id: 'temp-node', position: { x: 1, y: 1 }, data: { type: 'answer', _isTempNode: true } }, + ]) + workflowStoreState = { + ...workflowStoreState, + syncWorkflowDraftHash: 'latest-hash', + environmentVariables: [{ id: 'env-1', value: 'env' }], + conversationVariables: [{ id: 'conversation-1', value: 'conversation' }], + } + featuresState = { + features: { + opening: { enabled: true, opening_statement: 'Hello', suggested_questions: ['Q1'] }, + suggested: { enabled: true }, + text2speech: { enabled: true }, + speech2text: { enabled: true }, + citation: { enabled: true }, + moderation: { enabled: false }, + file: { enabled: true }, + }, + } + + const callbacks = { + onSuccess: vi.fn(), + onError: vi.fn(), + onSettled: vi.fn(), + } + + const { result } = renderHook(() => useNodesSyncDraft()) + + await act(async () => { + await result.current.doSyncWorkflowDraft(false, callbacks) + }) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({ + url: '/apps/app-1/workflows/draft', + params: { + graph: { + nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', label: 'Start' } }], + edges: [{ id: 'edge-1', source: 'n1', target: 'n2', data: { stable: 'keep' } }], + viewport: { x: 10, y: 20, zoom: 1.5 }, + }, + features: { + opening_statement: 'Hello', + suggested_questions: ['Q1'], + suggested_questions_after_answer: { enabled: true }, + text_to_speech: { enabled: true }, + speech_to_text: { enabled: true }, + retriever_resource: { enabled: true }, + sensitive_word_avoidance: { enabled: false }, + file_upload: { enabled: true }, + }, + environment_variables: [{ id: 'env-1', value: 'env' }], + conversation_variables: [{ id: 'conversation-1', value: 'conversation' }], + hash: 'latest-hash', + }, + }) + expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new') + expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1) + expect(callbacks.onSuccess).toHaveBeenCalled() + expect(callbacks.onError).not.toHaveBeenCalled() + expect(callbacks.onSettled).toHaveBeenCalled() + }) + + it('should post workflow draft with keepalive when the page closes', () => { + reactFlowState = { + ...reactFlowState, + transform: [1, 2, 3], + } + workflowStoreState = { + ...workflowStoreState, + environmentVariables: [{ id: 'env-1' }], + conversationVariables: [{ id: 'conversation-1' }], + } + + const { result } = renderHook(() => useNodesSyncDraft()) + + act(() => { + result.current.syncWorkflowDraftWhenPageClose() + }) + + expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/apps/app-1/workflows/draft', expect.objectContaining({ + graph: expect.objectContaining({ + viewport: { x: 1, y: 2, zoom: 3 }, + }), + hash: 'hash-123', + })) + }) }) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts index 42e4b593ed..4827c5508c 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts @@ -1,5 +1,6 @@ import { renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockEnum } from '@/app/components/workflow/types' import { useWorkflowInit } from '../use-workflow-init' @@ -11,6 +12,21 @@ const mockSetLastPublishedHasUserInput = vi.fn() const mockSetFileUploadConfig = vi.fn() const mockWorkflowStoreSetState = vi.fn() const mockWorkflowStoreGetState = vi.fn() +const mockFetchNodesDefaultConfigs = vi.fn() +const mockFetchPublishedWorkflow = vi.fn() + +let appStoreState: { + appDetail: { + id: string + name: string + mode: string + } +} + +let workflowConfigState: { + data: Record | null + isLoading: boolean +} vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: { setSyncWorkflowDraftHash: ReturnType }) => T): T => @@ -22,8 +38,8 @@ vi.mock('@/app/components/workflow/store', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: { id: string, name: string, mode: string } }) => T): T => - selector({ appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' } }), + useStore: (selector: (state: typeof appStoreState) => T): T => + selector(appStoreState), })) vi.mock('../use-workflow-template', () => ({ @@ -31,7 +47,11 @@ vi.mock('../use-workflow-template', () => ({ })) vi.mock('@/service/use-workflow', () => ({ - useWorkflowConfig: () => ({ data: null, isLoading: false }), + useWorkflowConfig: (_url: string, onSuccess: (config: Record) => void) => { + if (workflowConfigState.data) + onSuccess(workflowConfigState.data) + return workflowConfigState + }, })) const mockFetchWorkflowDraft = vi.fn() @@ -40,8 +60,8 @@ const mockSyncWorkflowDraft = vi.fn() vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args), syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args), - fetchNodesDefaultConfigs: () => Promise.resolve([]), - fetchPublishedWorkflow: () => Promise.resolve({ created_at: 0, graph: { nodes: [], edges: [] } }), + fetchNodesDefaultConfigs: (...args: unknown[]) => mockFetchNodesDefaultConfigs(...args), + fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args), })) const notExistError = () => ({ @@ -68,6 +88,10 @@ const draftResponse = { describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => { beforeEach(() => { vi.clearAllMocks() + appStoreState = { + appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' }, + } + workflowConfigState = { data: null, isLoading: false } mockWorkflowStoreGetState.mockReturnValue({ setDraftUpdatedAt: mockSetDraftUpdatedAt, setToolPublished: mockSetToolPublished, @@ -75,6 +99,8 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => { setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput, setFileUploadConfig: mockSetFileUploadConfig, }) + mockFetchNodesDefaultConfigs.mockResolvedValue([]) + mockFetchPublishedWorkflow.mockResolvedValue({ created_at: 0, graph: { nodes: [], edges: [] } }) mockFetchWorkflowDraft .mockRejectedValueOnce(notExistError()) .mockResolvedValueOnce(draftResponse) @@ -104,4 +130,77 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => { expect(order).toContain('hash:new-hash') expect(order.indexOf('hash:new-hash')).toBeLessThan(order.indexOf('fetch:2')) }) + + it('should hydrate draft state, preload defaults, and derive published workflow metadata on success', async () => { + workflowConfigState = { + data: { enabled: true, sizeLimit: 20 }, + isLoading: false, + } + mockFetchWorkflowDraft.mockReset().mockResolvedValue({ + ...draftResponse, + updated_at: 9, + tool_published: true, + environment_variables: [ + { id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' }, + { id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' }, + ], + conversation_variables: [{ id: 'conversation-1' }], + }) + mockFetchNodesDefaultConfigs.mockResolvedValue([ + { type: 'start', config: { title: 'Start Config' } }, + { type: 'start', config: { title: 'Ignored Duplicate' } }, + ]) + mockFetchPublishedWorkflow.mockResolvedValue({ + created_at: 99, + graph: { + nodes: [{ id: 'start', data: { type: BlockEnum.Start } }], + edges: [{ source: 'start', target: 'end' }], + }, + }) + + const { result } = renderHook(() => useWorkflowInit()) + + await waitFor(() => { + expect(result.current.data?.hash).toBe('server-hash') + }) + + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ appId: 'app-1', appName: 'Test' }) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith(expect.objectContaining({ + envSecrets: { 'env-secret': 'top-secret' }, + environmentVariables: [ + { id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' }, + { id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' }, + ], + conversationVariables: [{ id: 'conversation-1' }], + isWorkflowDataLoaded: true, + })) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ + nodesDefaultConfigs: { + start: { title: 'Start Config' }, + }, + }) + expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash') + expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(9) + expect(mockSetToolPublished).toHaveBeenCalledWith(true) + expect(mockSetPublishedAt).toHaveBeenCalledWith(99) + expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true) + expect(mockSetFileUploadConfig).toHaveBeenCalledWith({ enabled: true, sizeLimit: 20 }) + expect(result.current.fileUploadConfigResponse).toEqual({ enabled: true, sizeLimit: 20 }) + expect(result.current.isLoading).toBe(false) + }) + + it('should fall back to no published user input when preload requests fail', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse) + mockFetchNodesDefaultConfigs.mockRejectedValue(new Error('preload failed')) + + renderHook(() => useWorkflowInit()) + + await waitFor(() => { + expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(false) + }) + + expect(consoleErrorSpy).toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) }) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts index 2fd06e587b..209f9d9c0e 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts @@ -1,24 +1,32 @@ -import { act, renderHook } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft' const mockHandleUpdateWorkflowCanvas = vi.fn() const mockSetSyncWorkflowDraftHash = vi.fn() +const mockSetIsSyncingWorkflowDraft = vi.fn() +const mockSetEnvironmentVariables = vi.fn() +const mockSetEnvSecrets = vi.fn() +const mockSetConversationVariables = vi.fn() +const mockSetIsWorkflowDataLoaded = vi.fn() +const mockCancel = vi.fn() + +let workflowStoreState: { + appId: string + isWorkflowDataLoaded: boolean + debouncedSyncWorkflowDraft?: { cancel: () => void } + setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash + setIsSyncingWorkflowDraft: typeof mockSetIsSyncingWorkflowDraft + setEnvironmentVariables: typeof mockSetEnvironmentVariables + setEnvSecrets: typeof mockSetEnvSecrets + setConversationVariables: typeof mockSetConversationVariables + setIsWorkflowDataLoaded: typeof mockSetIsWorkflowDataLoaded +} vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => ({ - getState: () => ({ - appId: 'app-1', - isWorkflowDataLoaded: true, - debouncedSyncWorkflowDraft: undefined, - setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash, - setIsSyncingWorkflowDraft: vi.fn(), - setEnvironmentVariables: vi.fn(), - setEnvSecrets: vi.fn(), - setConversationVariables: vi.fn(), - setIsWorkflowDataLoaded: vi.fn(), - }), + getState: () => workflowStoreState, }), })) @@ -41,6 +49,17 @@ const draftResponse = { describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { beforeEach(() => { vi.clearAllMocks() + workflowStoreState = { + appId: 'app-1', + isWorkflowDataLoaded: true, + debouncedSyncWorkflowDraft: undefined, + setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash, + setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft, + setEnvironmentVariables: mockSetEnvironmentVariables, + setEnvSecrets: mockSetEnvSecrets, + setConversationVariables: mockSetConversationVariables, + setIsWorkflowDataLoaded: mockSetIsWorkflowDataLoaded, + } mockFetchWorkflowDraft.mockResolvedValue(draftResponse) }) @@ -75,6 +94,67 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => { await act(async () => { result.current.handleRefreshWorkflowDraft(true) }) - expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash') + await waitFor(() => { + expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash') + }) + }) + + it('should cancel pending draft sync, use fallback viewport, and persist masked secrets', async () => { + workflowStoreState = { + ...workflowStoreState, + debouncedSyncWorkflowDraft: { cancel: mockCancel }, + } + mockFetchWorkflowDraft.mockResolvedValue({ + hash: 'server-hash', + graph: { + nodes: [{ id: 'n1' }], + edges: [], + }, + environment_variables: [ + { id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' }, + { id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' }, + ], + conversation_variables: [{ id: 'conversation-1' }], + }) + + const { result } = renderHook(() => useWorkflowRefreshDraft()) + + act(() => { + result.current.handleRefreshWorkflowDraft() + }) + + await waitFor(() => { + expect(mockCancel).toHaveBeenCalled() + expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ + nodes: [{ id: 'n1' }], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }) + expect(mockSetEnvSecrets).toHaveBeenCalledWith({ + 'env-secret': 'top-secret', + }) + expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([ + { id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' }, + { id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' }, + ]) + expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }]) + }) + }) + + it('should restore loaded state when refresh fails after workflow data was already loaded', async () => { + mockFetchWorkflowDraft.mockRejectedValue(new Error('refresh failed')) + + const { result } = renderHook(() => useWorkflowRefreshDraft()) + + act(() => { + result.current.handleRefreshWorkflowDraft() + }) + + await waitFor(() => { + expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(1, false) + expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(2, true) + expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true) + expect(mockSetIsSyncingWorkflowDraft).toHaveBeenLastCalledWith(false) + }) }) }) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts new file mode 100644 index 0000000000..880d5e56e6 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts @@ -0,0 +1,451 @@ +import type AudioPlayer from '@/app/components/base/audio-btn/audio' +import { createBaseWorkflowRunCallbacks, createFinalWorkflowRunCallbacks } from '../use-workflow-run-callbacks' + +const { + mockSseGet, + mockResetMsgId, +} = vi.hoisted(() => ({ + mockSseGet: vi.fn(), + mockResetMsgId: vi.fn(), +})) + +vi.mock('@/service/base', () => ({ + sseGet: mockSseGet, +})) + +vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ + AudioPlayerManager: { + getInstance: () => ({ + resetMsgId: mockResetMsgId, + }), + }, +})) + +const createHandlers = () => ({ + handleWorkflowStarted: vi.fn(), + handleWorkflowFinished: vi.fn(), + handleWorkflowFailed: vi.fn(), + handleWorkflowNodeStarted: vi.fn(), + handleWorkflowNodeFinished: vi.fn(), + handleWorkflowNodeHumanInputRequired: vi.fn(), + handleWorkflowNodeHumanInputFormFilled: vi.fn(), + handleWorkflowNodeHumanInputFormTimeout: 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(), + handleWorkflowPaused: vi.fn(), +}) + +const createUserCallbacks = () => ({ + onWorkflowStarted: vi.fn(), + onWorkflowFinished: vi.fn(), + onNodeStarted: vi.fn(), + onNodeFinished: vi.fn(), + onIterationStart: vi.fn(), + onIterationNext: vi.fn(), + onIterationFinish: vi.fn(), + onLoopStart: vi.fn(), + onLoopNext: vi.fn(), + onLoopFinish: vi.fn(), + onNodeRetry: vi.fn(), + onAgentLog: vi.fn(), + onError: vi.fn(), + onWorkflowPaused: vi.fn(), + onHumanInputRequired: vi.fn(), + onHumanInputFormFilled: vi.fn(), + onHumanInputFormTimeout: vi.fn(), + onCompleted: vi.fn(), +}) + +describe('useWorkflowRun callbacks helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should create base callbacks that wrap workflow events, errors, pause continuation, and lazy tts playback', () => { + const handlers = createHandlers() + const clearAbortController = vi.fn() + const clearListeningState = vi.fn() + const invalidateRunHistory = vi.fn() + const fetchInspectVars = vi.fn() + const invalidAllLastRun = vi.fn() + const trackWorkflowRunFailed = vi.fn() + const userOnWorkflowFinished = vi.fn() + const userOnError = vi.fn() + const userOnWorkflowPaused = vi.fn() + const player = { + playAudioWithAudio: vi.fn(), + } as unknown as AudioPlayer + const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => player) + + const callbacks = createBaseWorkflowRunCallbacks({ + clientWidth: 320, + clientHeight: 240, + runHistoryUrl: '/apps/app-1/workflow-runs', + isInWorkflowDebug: true, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + clearAbortController, + clearListeningState, + trackWorkflowRunFailed, + handlers, + callbacks: { + onWorkflowFinished: userOnWorkflowFinished, + onError: userOnError, + onWorkflowPaused: userOnWorkflowPaused, + }, + restCallback: {}, + getOrCreatePlayer, + }) + + callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never) + expect(clearListeningState).toHaveBeenCalled() + expect(handlers.handleWorkflowFinished).toHaveBeenCalled() + expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs') + expect(userOnWorkflowFinished).toHaveBeenCalled() + expect(fetchInspectVars).toHaveBeenCalledWith({}) + expect(invalidAllLastRun).toHaveBeenCalled() + + callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never) + expect(clearAbortController).toHaveBeenCalled() + expect(handlers.handleWorkflowFailed).toHaveBeenCalled() + expect(userOnError).toHaveBeenCalled() + expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }) + + callbacks.onTTSChunk?.('message-1', 'audio-chunk') + expect(getOrCreatePlayer).toHaveBeenCalled() + expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true) + expect(mockResetMsgId).toHaveBeenCalledWith('message-1') + + callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never) + expect(handlers.handleWorkflowPaused).toHaveBeenCalled() + expect(userOnWorkflowPaused).toHaveBeenCalled() + expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks) + }) + + it('should create final callbacks that preserve rest callback override order and eager abort-controller wiring', () => { + const handlers = createHandlers() + const restOnNodeStarted = vi.fn() + const setAbortController = vi.fn() + const player = { + playAudioWithAudio: vi.fn(), + } as unknown as AudioPlayer + + const baseSseOptions = createBaseWorkflowRunCallbacks({ + clientWidth: 320, + clientHeight: 240, + runHistoryUrl: '/apps/app-1/workflow-runs', + isInWorkflowDebug: false, + fetchInspectVars: vi.fn(), + invalidAllLastRun: vi.fn(), + invalidateRunHistory: vi.fn(), + clearAbortController: vi.fn(), + clearListeningState: vi.fn(), + trackWorkflowRunFailed: vi.fn(), + handlers, + callbacks: {}, + restCallback: {}, + getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player), + }) + + const finalCallbacks = createFinalWorkflowRunCallbacks({ + clientWidth: 320, + clientHeight: 240, + runHistoryUrl: '/apps/app-1/workflow-runs', + isInWorkflowDebug: false, + fetchInspectVars: vi.fn(), + invalidAllLastRun: vi.fn(), + invalidateRunHistory: vi.fn(), + clearAbortController: vi.fn(), + clearListeningState: vi.fn(), + trackWorkflowRunFailed: vi.fn(), + handlers, + callbacks: {}, + restCallback: { + onNodeStarted: restOnNodeStarted, + }, + baseSseOptions, + player, + setAbortController, + }) + + const controller = new AbortController() + finalCallbacks.getAbortController?.(controller) + expect(setAbortController).toHaveBeenCalledWith(controller) + + finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never) + expect(restOnNodeStarted).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeStarted).not.toHaveBeenCalled() + + finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk') + expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true) + expect(mockResetMsgId).toHaveBeenCalledWith('message-2') + }) + + it('should route base workflow events through handlers, user callbacks, and pause continuation with the same callback object', async () => { + const handlers = createHandlers() + const userCallbacks = createUserCallbacks() + const clearAbortController = vi.fn() + const clearListeningState = vi.fn() + const invalidateRunHistory = vi.fn() + const fetchInspectVars = vi.fn() + const invalidAllLastRun = vi.fn() + const trackWorkflowRunFailed = vi.fn() + const player = { + playAudioWithAudio: vi.fn(), + } as unknown as AudioPlayer + + const callbacks = createBaseWorkflowRunCallbacks({ + clientWidth: 640, + clientHeight: 360, + runHistoryUrl: '/apps/app-1/workflow-runs', + isInWorkflowDebug: true, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + clearAbortController, + clearListeningState, + trackWorkflowRunFailed, + handlers, + callbacks: userCallbacks, + restCallback: {}, + getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player), + }) + + callbacks.onWorkflowStarted?.({ workflow_run_id: 'run-1' } as never) + callbacks.onNodeStarted?.({ node_id: 'node-1' } as never) + callbacks.onNodeFinished?.({ node_id: 'node-1' } as never) + callbacks.onIterationStart?.({ node_id: 'node-1' } as never) + callbacks.onIterationNext?.({ node_id: 'node-1' } as never) + callbacks.onIterationFinish?.({ node_id: 'node-1' } as never) + callbacks.onLoopStart?.({ node_id: 'node-1' } as never) + callbacks.onLoopNext?.({ node_id: 'node-1' } as never) + callbacks.onLoopFinish?.({ node_id: 'node-1' } as never) + callbacks.onNodeRetry?.({ node_id: 'node-1' } as never) + callbacks.onAgentLog?.({ node_id: 'node-1' } as never) + callbacks.onTextChunk?.({ data: 'chunk' } as never) + callbacks.onTextReplace?.({ text: 'replacement' } as never) + callbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never) + callbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never) + callbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never) + callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never) + await callbacks.onCompleted?.(false, '') + callbacks.onTTSChunk?.('message-1', 'audio-chunk') + callbacks.onTTSEnd?.('message-1', 'audio-finished') + callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never) + callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never, '500') + + expect(handlers.handleWorkflowStarted).toHaveBeenCalled() + expect(userCallbacks.onWorkflowStarted).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith( + { node_id: 'node-1' }, + { clientWidth: 640, clientHeight: 360 }, + ) + expect(userCallbacks.onNodeStarted).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeFinished).toHaveBeenCalled() + expect(userCallbacks.onNodeFinished).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith( + { node_id: 'node-1' }, + { clientWidth: 640, clientHeight: 360 }, + ) + expect(userCallbacks.onIterationStart).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeIterationNext).toHaveBeenCalled() + expect(userCallbacks.onIterationNext).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeIterationFinished).toHaveBeenCalled() + expect(userCallbacks.onIterationFinish).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith( + { node_id: 'node-1' }, + { clientWidth: 640, clientHeight: 360 }, + ) + expect(userCallbacks.onLoopStart).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeLoopNext).toHaveBeenCalled() + expect(userCallbacks.onLoopNext).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeLoopFinished).toHaveBeenCalled() + expect(userCallbacks.onLoopFinish).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeRetry).toHaveBeenCalled() + expect(userCallbacks.onNodeRetry).toHaveBeenCalled() + expect(handlers.handleWorkflowAgentLog).toHaveBeenCalled() + expect(userCallbacks.onAgentLog).toHaveBeenCalled() + expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled() + expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled() + expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled() + expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled() + expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled() + expect(clearListeningState).toHaveBeenCalled() + expect(handlers.handleWorkflowFinished).toHaveBeenCalled() + expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled() + expect(fetchInspectVars).toHaveBeenCalledWith({}) + expect(invalidAllLastRun).toHaveBeenCalled() + expect(userCallbacks.onCompleted).toHaveBeenCalledWith(false, '') + expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true) + expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false) + expect(mockResetMsgId).toHaveBeenCalledWith('message-1') + expect(handlers.handleWorkflowPaused).toHaveBeenCalled() + expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled() + expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks) + expect(clearAbortController).toHaveBeenCalled() + expect(handlers.handleWorkflowFailed).toHaveBeenCalled() + expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }, '500') + expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }) + expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs') + }) + + it('should skip base debug-only side effects and audio playback when debug mode is off or audio is empty', () => { + const handlers = createHandlers() + const fetchInspectVars = vi.fn() + const invalidAllLastRun = vi.fn() + const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => null) + + const callbacks = createBaseWorkflowRunCallbacks({ + clientWidth: 320, + clientHeight: 240, + runHistoryUrl: '/apps/app-1/workflow-runs', + isInWorkflowDebug: false, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory: vi.fn(), + clearAbortController: vi.fn(), + clearListeningState: vi.fn(), + trackWorkflowRunFailed: vi.fn(), + handlers, + callbacks: {}, + restCallback: {}, + getOrCreatePlayer, + }) + + callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never) + callbacks.onTTSChunk?.('message-1', '') + callbacks.onTTSEnd?.('message-1', 'audio-finished') + + expect(fetchInspectVars).not.toHaveBeenCalled() + expect(invalidAllLastRun).not.toHaveBeenCalled() + expect(getOrCreatePlayer).toHaveBeenCalledTimes(1) + expect(mockResetMsgId).not.toHaveBeenCalled() + }) + + it('should route final workflow events through handlers and continue paused runs with final callbacks', async () => { + const handlers = createHandlers() + const userCallbacks = createUserCallbacks() + const fetchInspectVars = vi.fn() + const invalidAllLastRun = vi.fn() + const invalidateRunHistory = vi.fn() + const setAbortController = vi.fn() + const player = { + playAudioWithAudio: vi.fn(), + } as unknown as AudioPlayer + + const baseSseOptions = createBaseWorkflowRunCallbacks({ + clientWidth: 480, + clientHeight: 320, + runHistoryUrl: '/apps/app-1/workflow-runs', + isInWorkflowDebug: false, + fetchInspectVars: vi.fn(), + invalidAllLastRun: vi.fn(), + invalidateRunHistory: vi.fn(), + clearAbortController: vi.fn(), + clearListeningState: vi.fn(), + trackWorkflowRunFailed: vi.fn(), + handlers, + callbacks: {}, + restCallback: {}, + getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player), + }) + + const finalCallbacks = createFinalWorkflowRunCallbacks({ + clientWidth: 480, + clientHeight: 320, + runHistoryUrl: '/apps/app-1/workflow-runs', + isInWorkflowDebug: true, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + clearAbortController: vi.fn(), + clearListeningState: vi.fn(), + trackWorkflowRunFailed: vi.fn(), + handlers, + callbacks: userCallbacks, + restCallback: {}, + baseSseOptions, + player, + setAbortController, + }) + + finalCallbacks.getAbortController?.(new AbortController()) + finalCallbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never) + finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never) + finalCallbacks.onNodeFinished?.({ node_id: 'node-1' } as never) + finalCallbacks.onIterationStart?.({ node_id: 'node-1' } as never) + finalCallbacks.onIterationNext?.({ node_id: 'node-1' } as never) + finalCallbacks.onIterationFinish?.({ node_id: 'node-1' } as never) + finalCallbacks.onLoopStart?.({ node_id: 'node-1' } as never) + finalCallbacks.onLoopNext?.({ node_id: 'node-1' } as never) + finalCallbacks.onLoopFinish?.({ node_id: 'node-1' } as never) + finalCallbacks.onNodeRetry?.({ node_id: 'node-1' } as never) + finalCallbacks.onAgentLog?.({ node_id: 'node-1' } as never) + finalCallbacks.onTextChunk?.({ data: 'chunk' } as never) + finalCallbacks.onTextReplace?.({ text: 'replacement' } as never) + finalCallbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never) + finalCallbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never) + finalCallbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never) + finalCallbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never) + finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk') + finalCallbacks.onTTSEnd?.('message-2', 'audio-finished') + await finalCallbacks.onCompleted?.(true, 'done') + finalCallbacks.onError?.({ error: 'failed' } as never, '500') + + expect(setAbortController).toHaveBeenCalled() + expect(handlers.handleWorkflowFinished).toHaveBeenCalled() + expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled() + expect(fetchInspectVars).toHaveBeenCalledWith({}) + expect(invalidAllLastRun).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith( + { node_id: 'node-1' }, + { clientWidth: 480, clientHeight: 320 }, + ) + expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith( + { node_id: 'node-1' }, + { clientWidth: 480, clientHeight: 320 }, + ) + expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith( + { node_id: 'node-1' }, + { clientWidth: 480, clientHeight: 320 }, + ) + expect(userCallbacks.onNodeStarted).toHaveBeenCalled() + expect(userCallbacks.onNodeFinished).toHaveBeenCalled() + expect(userCallbacks.onIterationStart).toHaveBeenCalled() + expect(userCallbacks.onIterationNext).toHaveBeenCalled() + expect(userCallbacks.onIterationFinish).toHaveBeenCalled() + expect(userCallbacks.onLoopStart).toHaveBeenCalled() + expect(userCallbacks.onLoopNext).toHaveBeenCalled() + expect(userCallbacks.onLoopFinish).toHaveBeenCalled() + expect(userCallbacks.onNodeRetry).toHaveBeenCalled() + expect(userCallbacks.onAgentLog).toHaveBeenCalled() + expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled() + expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled() + expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled() + expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled() + expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled() + expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled() + expect(handlers.handleWorkflowPaused).toHaveBeenCalled() + expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled() + expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, finalCallbacks) + expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true) + expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false) + expect(handlers.handleWorkflowFailed).toHaveBeenCalled() + expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed' }, '500') + expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs') + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-utils.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-utils.spec.ts new file mode 100644 index 0000000000..a83d2f55ee --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run-utils.spec.ts @@ -0,0 +1,431 @@ +import { TriggerType } from '@/app/components/workflow/header/test-run-menu' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { AppModeEnum } from '@/types/app' +import { + applyRunningStateForMode, + applyStoppedState, + buildListeningTriggerNodeIds, + buildRunHistoryUrl, + buildTTSConfig, + buildWorkflowRunRequestBody, + clearListeningState, + clearWindowDebugControllers, + createFailedWorkflowState, + createRunningWorkflowState, + createStoppedWorkflowState, + mapPublishedWorkflowFeatures, + normalizePublishedWorkflowNodes, + resolveWorkflowRunUrl, + runTriggerDebug, + validateWorkflowRunRequest, +} from '../use-workflow-run-utils' + +const { + mockPost, + mockHandleStream, + mockToastError, +} = vi.hoisted(() => ({ + mockPost: vi.fn(), + mockHandleStream: vi.fn(), + mockToastError: vi.fn(), +})) + +vi.mock('@/service/base', () => ({ + post: mockPost, + handleStream: mockHandleStream, +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: mockToastError, + }, +})) + +const createListeningActions = () => ({ + setWorkflowRunningData: vi.fn(), + setIsListening: vi.fn(), + setShowVariableInspectPanel: vi.fn(), + setListeningTriggerType: vi.fn(), + setListeningTriggerNodeIds: vi.fn(), + setListeningTriggerIsAll: vi.fn(), + setListeningTriggerNodeId: vi.fn(), +}) + +describe('useWorkflowRun utils', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should resolve run history urls and run endpoints for workflow modes', () => { + expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW })).toBe('/apps/app-1/workflow-runs') + expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT })).toBe('/apps/app-1/advanced-chat/workflow-runs') + + expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, true)).toBe('/apps/app-1/workflows/draft/run') + expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }, TriggerType.UserInput, false)).toBe('/apps/app-1/advanced-chat/workflows/draft/run') + expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.Schedule, true)).toBe('/apps/app-1/workflows/draft/trigger/run') + expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.All, true)).toBe('/apps/app-1/workflows/draft/trigger/run-all') + }) + + it('should build request bodies and validation errors for trigger runs', () => { + expect(buildWorkflowRunRequestBody(TriggerType.Schedule, {}, { scheduleNodeId: 'schedule-1' })).toEqual({ node_id: 'schedule-1' }) + expect(buildWorkflowRunRequestBody(TriggerType.Webhook, {}, { webhookNodeId: 'webhook-1' })).toEqual({ node_id: 'webhook-1' }) + expect(buildWorkflowRunRequestBody(TriggerType.Plugin, {}, { pluginNodeId: 'plugin-1' })).toEqual({ node_id: 'plugin-1' }) + expect(buildWorkflowRunRequestBody(TriggerType.All, {}, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual({ node_ids: ['trigger-1', 'trigger-2'] }) + expect(buildWorkflowRunRequestBody(TriggerType.UserInput, { inputs: { query: 'hello' } })).toEqual({ inputs: { query: 'hello' } }) + + expect(validateWorkflowRunRequest(TriggerType.Schedule)).toBe('handleRun: schedule trigger run requires node id') + expect(validateWorkflowRunRequest(TriggerType.Webhook)).toBe('handleRun: webhook trigger run requires node id') + expect(validateWorkflowRunRequest(TriggerType.Plugin)).toBe('handleRun: plugin trigger run requires node id') + expect(validateWorkflowRunRequest(TriggerType.All)).toBe('') + expect(validateWorkflowRunRequest(TriggerType.All, { allNodeIds: [] })).toBe('') + }) + + it('should return empty trigger urls when app id is missing and keep user-input urls empty outside workflow debug', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(resolveWorkflowRunUrl(undefined, TriggerType.Plugin, true)).toBe('') + expect(resolveWorkflowRunUrl(undefined, TriggerType.All, true)).toBe('') + expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, false)).toBe('') + + expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger plugin run') + expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger run all') + + consoleErrorSpy.mockRestore() + }) + + it('should configure listening state for trigger and non-trigger modes', () => { + const triggerActions = createListeningActions() + + applyRunningStateForMode(triggerActions, TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] }) + + expect(triggerActions.setIsListening).toHaveBeenCalledWith(true) + expect(triggerActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true) + expect(triggerActions.setListeningTriggerIsAll).toHaveBeenCalledWith(true) + expect(triggerActions.setListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2']) + expect(triggerActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState()) + + const normalActions = createListeningActions() + applyRunningStateForMode(normalActions, TriggerType.UserInput) + + expect(normalActions.setIsListening).toHaveBeenCalledWith(false) + expect(normalActions.setListeningTriggerType).toHaveBeenCalledWith(null) + expect(normalActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null) + expect(normalActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([]) + expect(normalActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false) + expect(normalActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState()) + }) + + it('should clear listening state, stop state, and remove debug controllers', () => { + const listeningActions = createListeningActions() + clearListeningState(listeningActions) + + expect(listeningActions.setIsListening).toHaveBeenCalledWith(false) + expect(listeningActions.setListeningTriggerType).toHaveBeenCalledWith(null) + expect(listeningActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null) + expect(listeningActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([]) + expect(listeningActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false) + + const stoppedActions = createListeningActions() + applyStoppedState(stoppedActions) + + expect(stoppedActions.setWorkflowRunningData).toHaveBeenCalledWith(createStoppedWorkflowState()) + expect(stoppedActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true) + + const controllerTarget = { + __webhookDebugAbortController: { abort: vi.fn() }, + __pluginDebugAbortController: { abort: vi.fn() }, + __scheduleDebugAbortController: { abort: vi.fn() }, + __allTriggersDebugAbortController: { abort: vi.fn() }, + } + clearWindowDebugControllers(controllerTarget) + expect(controllerTarget).toEqual({}) + }) + + it('should derive listening node ids, tts config, and published workflow mappings', () => { + expect(buildListeningTriggerNodeIds(TriggerType.Webhook, { webhookNodeId: 'webhook-1' })).toEqual(['webhook-1']) + expect(buildListeningTriggerNodeIds(TriggerType.Schedule, { scheduleNodeId: 'schedule-1' })).toEqual(['schedule-1']) + expect(buildListeningTriggerNodeIds(TriggerType.Plugin, { pluginNodeId: 'plugin-1' })).toEqual(['plugin-1']) + expect(buildListeningTriggerNodeIds(TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual(['trigger-1', 'trigger-2']) + + expect(buildTTSConfig({ token: 'public-token' }, '/apps/app-1')).toEqual({ + ttsUrl: '/text-to-audio', + ttsIsPublic: true, + }) + expect(buildTTSConfig({ appId: 'app-1' }, '/explore/installed/app-1')).toEqual({ + ttsUrl: '/installed-apps/app-1/text-to-audio', + ttsIsPublic: false, + }) + expect(buildTTSConfig({ appId: 'app-1' }, '/apps/app-1/workflow')).toEqual({ + ttsUrl: '/apps/app-1/text-to-audio', + ttsIsPublic: false, + }) + + const publishedWorkflow = { + graph: { + nodes: [{ id: 'node-1', selected: true, data: { selected: true, title: 'Start' } }], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: { + opening_statement: 'hello', + suggested_questions: ['Q1'], + suggested_questions_after_answer: { enabled: true }, + text_to_speech: { enabled: true }, + speech_to_text: { enabled: true }, + retriever_resource: { enabled: true }, + sensitive_word_avoidance: { enabled: true }, + file_upload: { enabled: true }, + }, + } as never + + expect(normalizePublishedWorkflowNodes(publishedWorkflow)).toEqual([ + { id: 'node-1', selected: false, data: { selected: false, title: 'Start' } }, + ]) + expect(mapPublishedWorkflowFeatures(publishedWorkflow)).toMatchObject({ + opening: { + enabled: true, + opening_statement: 'hello', + suggested_questions: ['Q1'], + }, + suggested: { enabled: true }, + text2speech: { enabled: true }, + speech2text: { enabled: true }, + citation: { enabled: true }, + moderation: { enabled: true }, + file: { enabled: true }, + }) + }) + + it('should handle trigger debug null and invalid json responses as request failures', async () => { + const clearAbortController = vi.fn() + const clearListeningStateSpy = vi.fn() + const setAbortController = vi.fn() + const setWorkflowRunningData = vi.fn() + const controllerTarget: Record = {} + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + mockPost.mockResolvedValueOnce(null) + + await runTriggerDebug({ + debugType: TriggerType.Webhook, + url: '/apps/app-1/workflows/draft/trigger/run', + requestBody: { node_id: 'webhook-1' }, + baseSseOptions: {}, + controllerTarget, + setAbortController, + clearAbortController, + clearListeningState: clearListeningStateSpy, + setWorkflowRunningData, + }) + + expect(mockToastError).toHaveBeenCalledWith('Webhook debug request failed') + expect(clearAbortController).toHaveBeenCalledTimes(1) + expect(clearListeningStateSpy).not.toHaveBeenCalled() + + mockPost.mockResolvedValueOnce(new Response('{invalid-json}', { + headers: { 'content-type': 'application/json' }, + })) + + await runTriggerDebug({ + debugType: TriggerType.Schedule, + url: '/apps/app-1/workflows/draft/trigger/run', + requestBody: { node_id: 'schedule-1' }, + baseSseOptions: {}, + controllerTarget, + setAbortController, + clearAbortController, + clearListeningState: clearListeningStateSpy, + setWorkflowRunningData, + }) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'handleRun: schedule debug response parse error', + expect.any(Error), + ) + expect(mockToastError).toHaveBeenCalledWith('Schedule debug request failed') + expect(clearAbortController).toHaveBeenCalledTimes(2) + expect(clearListeningStateSpy).toHaveBeenCalledTimes(1) + expect(setWorkflowRunningData).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) + + it('should handle trigger debug json failures and stream responses', async () => { + const clearAbortController = vi.fn() + const clearListeningStateSpy = vi.fn() + const setAbortController = vi.fn() + const setWorkflowRunningData = vi.fn() + const controllerTarget: Record = {} + const baseSseOptions = { + onData: vi.fn(), + onCompleted: vi.fn(), + } + + mockPost.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Webhook failed' }), { + headers: { 'content-type': 'application/json' }, + })) + + await runTriggerDebug({ + debugType: TriggerType.Webhook, + url: '/apps/app-1/workflows/draft/trigger/run', + requestBody: { node_id: 'webhook-1' }, + baseSseOptions, + controllerTarget, + setAbortController, + clearAbortController, + clearListeningState: clearListeningStateSpy, + setWorkflowRunningData, + }) + + expect(setAbortController).toHaveBeenCalledTimes(1) + expect(mockToastError).toHaveBeenCalledWith('Webhook failed') + expect(clearAbortController).toHaveBeenCalled() + expect(clearListeningStateSpy).toHaveBeenCalled() + expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Webhook failed')) + + mockPost.mockResolvedValueOnce(new Response('data: ok', { + headers: { 'content-type': 'text/event-stream' }, + })) + + await runTriggerDebug({ + debugType: TriggerType.Plugin, + url: '/apps/app-1/workflows/draft/trigger/run', + requestBody: { node_id: 'plugin-1' }, + baseSseOptions, + controllerTarget, + setAbortController, + clearAbortController, + clearListeningState: clearListeningStateSpy, + setWorkflowRunningData, + }) + + expect(clearListeningStateSpy).toHaveBeenCalledTimes(2) + expect(mockHandleStream).toHaveBeenCalledTimes(1) + }) + + it('should retry waiting trigger debug responses until a stream is returned', async () => { + vi.useFakeTimers() + const clearAbortController = vi.fn() + const clearListeningStateSpy = vi.fn() + const setAbortController = vi.fn() + const setWorkflowRunningData = vi.fn() + const controllerTarget: Record = {} + const baseSseOptions = { + onData: vi.fn(), + onCompleted: vi.fn(), + } + + mockPost + .mockResolvedValueOnce(new Response(JSON.stringify({ status: 'waiting', retry_in: 1 }), { + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response('data: ok', { + headers: { 'content-type': 'text/event-stream' }, + })) + + const runPromise = runTriggerDebug({ + debugType: TriggerType.All, + url: '/apps/app-1/workflows/draft/trigger/run-all', + requestBody: { node_ids: ['trigger-1'] }, + baseSseOptions, + controllerTarget, + setAbortController, + clearAbortController, + clearListeningState: clearListeningStateSpy, + setWorkflowRunningData, + }) + + await vi.advanceTimersByTimeAsync(1) + await runPromise + + expect(mockPost).toHaveBeenCalledTimes(2) + expect(clearListeningStateSpy).toHaveBeenCalledTimes(1) + expect(mockHandleStream).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) + + it('should stop trigger debug processing when the controller aborts before handling the response', async () => { + const clearAbortController = vi.fn() + const clearListeningStateSpy = vi.fn() + const setWorkflowRunningData = vi.fn() + const controllerTarget: Record = {} + + mockPost.mockResolvedValueOnce(new Response('data: ok', { + headers: { 'content-type': 'text/event-stream' }, + })) + + await runTriggerDebug({ + debugType: TriggerType.Plugin, + url: '/apps/app-1/workflows/draft/trigger/run', + requestBody: { node_id: 'plugin-1' }, + baseSseOptions: {}, + controllerTarget, + setAbortController: (controller) => { + controller?.abort() + }, + clearAbortController, + clearListeningState: clearListeningStateSpy, + setWorkflowRunningData, + }) + + expect(mockHandleStream).not.toHaveBeenCalled() + expect(mockToastError).not.toHaveBeenCalled() + expect(clearAbortController).not.toHaveBeenCalled() + expect(clearListeningStateSpy).not.toHaveBeenCalled() + expect(setWorkflowRunningData).not.toHaveBeenCalled() + }) + + it('should handle Response and non-Response trigger debug exceptions correctly', async () => { + const clearAbortController = vi.fn() + const clearListeningStateSpy = vi.fn() + const setAbortController = vi.fn() + const setWorkflowRunningData = vi.fn() + const controllerTarget: Record = {} + + mockPost.mockRejectedValueOnce(new Response(JSON.stringify({ error: 'Plugin failed' }), { + headers: { 'content-type': 'application/json' }, + })) + + await runTriggerDebug({ + debugType: TriggerType.Plugin, + url: '/apps/app-1/workflows/draft/trigger/run', + requestBody: { node_id: 'plugin-1' }, + baseSseOptions: {}, + controllerTarget, + setAbortController, + clearAbortController, + clearListeningState: clearListeningStateSpy, + setWorkflowRunningData, + }) + + expect(mockToastError).toHaveBeenCalledWith('Plugin failed') + expect(clearAbortController).toHaveBeenCalledTimes(1) + expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Plugin failed')) + expect(clearListeningStateSpy).toHaveBeenCalledTimes(1) + + mockPost.mockRejectedValueOnce(new Error('network failed')) + + await runTriggerDebug({ + debugType: TriggerType.Plugin, + url: '/apps/app-1/workflows/draft/trigger/run', + requestBody: { node_id: 'plugin-1' }, + baseSseOptions: {}, + controllerTarget, + setAbortController, + clearAbortController, + clearListeningState: clearListeningStateSpy, + setWorkflowRunningData, + }) + + expect(clearAbortController).toHaveBeenCalledTimes(1) + expect(setWorkflowRunningData).toHaveBeenCalledTimes(1) + expect(clearListeningStateSpy).toHaveBeenCalledTimes(2) + }) + + it('should expose the canonical workflow state factories', () => { + expect(createRunningWorkflowState().result.status).toBe(WorkflowRunningStatus.Running) + expect(createStoppedWorkflowState().result.status).toBe(WorkflowRunningStatus.Stopped) + expect(createFailedWorkflowState('failed').result.status).toBe(WorkflowRunningStatus.Failed) + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts new file mode 100644 index 0000000000..7b54598774 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts @@ -0,0 +1,592 @@ +import { act, renderHook } from '@testing-library/react' +import { TriggerType } from '@/app/components/workflow/header/test-run-menu' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { useWorkflowRun } from '../use-workflow-run' + +type DebugAbortControllerRef = { + abort: () => void +} + +type DebugControllerWindow = Window & { + __webhookDebugAbortController?: DebugAbortControllerRef + __pluginDebugAbortController?: DebugAbortControllerRef + __scheduleDebugAbortController?: DebugAbortControllerRef + __allTriggersDebugAbortController?: DebugAbortControllerRef +} + +type WorkflowStoreState = { + backupDraft?: unknown + environmentVariables?: unknown + setBackupDraft?: (value: unknown) => void + setEnvironmentVariables?: (value: unknown) => void + setWorkflowRunningData?: (value: unknown) => void + setIsListening?: (value: boolean) => void + setShowVariableInspectPanel?: (value: boolean) => void + setListeningTriggerType?: (value: unknown) => void + setListeningTriggerNodeIds?: (value: string[]) => void + setListeningTriggerIsAll?: (value: boolean) => void + setListeningTriggerNodeId?: (value: string | null) => void +} + +const mocks = vi.hoisted(() => { + const appStoreState = { + appDetail: { + id: 'app-1', + mode: 'workflow', + name: 'Workflow App', + }, + } + const reactFlowStoreState = { + edges: [{ id: 'edge-1' }], + getNodes: vi.fn(), + setNodes: vi.fn(), + } + const workflowStoreState: WorkflowStoreState = {} + const workflowStoreSetState = vi.fn((partial: Record) => { + Object.assign(workflowStoreState, partial) + }) + const featuresStoreState = { + features: { + file: { + enabled: true, + }, + }, + } + const featuresStoreSetState = vi.fn((partial: Record) => { + Object.assign(featuresStoreState, partial) + }) + + return { + appStoreState, + reactFlowStoreState, + workflowStoreState, + workflowStoreSetState, + featuresStoreState, + featuresStoreSetState, + mockGetViewport: vi.fn(), + mockDoSyncWorkflowDraft: vi.fn(), + mockHandleUpdateWorkflowCanvas: vi.fn(), + mockFetchInspectVars: vi.fn(), + mockInvalidateAllLastRun: vi.fn(), + mockInvalidateRunHistory: vi.fn(), + mockSsePost: vi.fn(), + mockSseGet: vi.fn(), + mockHandleStream: vi.fn(), + mockPost: vi.fn(), + mockStopWorkflowRun: vi.fn(), + mockTrackEvent: vi.fn(), + mockGetAudioPlayer: vi.fn(), + mockResetMsgId: vi.fn(), + mockCreateBaseWorkflowRunCallbacks: vi.fn(), + mockCreateFinalWorkflowRunCallbacks: vi.fn(), + runEventHandlers: { + handleWorkflowStarted: vi.fn(), + handleWorkflowFinished: vi.fn(), + handleWorkflowFailed: vi.fn(), + handleWorkflowNodeStarted: vi.fn(), + handleWorkflowNodeFinished: vi.fn(), + handleWorkflowNodeHumanInputRequired: vi.fn(), + handleWorkflowNodeHumanInputFormFilled: vi.fn(), + handleWorkflowNodeHumanInputFormTimeout: 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(), + handleWorkflowPaused: vi.fn(), + }, + } +}) + +vi.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => mocks.reactFlowStoreState, + }), + useReactFlow: () => ({ + getViewport: mocks.mockGetViewport, + }), +})) + +vi.mock('@/app/components/app/store', () => { + const useStore = Object.assign(vi.fn(), { + getState: () => mocks.appStoreState, + }) + + return { + useStore, + } +}) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: mocks.mockTrackEvent, +})) + +vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ + AudioPlayerManager: { + getInstance: () => ({ + getAudioPlayer: mocks.mockGetAudioPlayer, + resetMsgId: mocks.mockResetMsgId, + }), + }, +})) + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeaturesStore: () => ({ + getState: () => mocks.featuresStoreState, + setState: mocks.featuresStoreSetState, + }), +})) + +vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({ + useWorkflowUpdate: () => ({ + handleUpdateWorkflowCanvas: mocks.mockHandleUpdateWorkflowCanvas, + }), +})) + +vi.mock('@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event', () => ({ + useWorkflowRunEvent: () => mocks.runEventHandlers, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => mocks.workflowStoreState, + setState: mocks.workflowStoreSetState, + }), +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: () => '/apps/app-1/workflow', +})) + +vi.mock('@/service/base', () => ({ + ssePost: mocks.mockSsePost, + sseGet: mocks.mockSseGet, + post: mocks.mockPost, + handleStream: mocks.mockHandleStream, +})) + +vi.mock('@/service/use-workflow', () => ({ + useInvalidAllLastRun: () => mocks.mockInvalidateAllLastRun, + useInvalidateWorkflowRunHistory: () => mocks.mockInvalidateRunHistory, + useInvalidateConversationVarValues: () => vi.fn(), + useInvalidateSysVarValues: () => vi.fn(), +})) + +vi.mock('@/service/workflow', () => ({ + stopWorkflowRun: mocks.mockStopWorkflowRun, +})) + +vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({ + useSetWorkflowVarsWithValue: () => ({ + fetchInspectVars: mocks.mockFetchInspectVars, + }), +})) + +vi.mock('../use-configs-map', () => ({ + useConfigsMap: () => ({ + flowId: 'flow-1', + flowType: 'workflow', + }), +})) + +vi.mock('../use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: mocks.mockDoSyncWorkflowDraft, + }), +})) + +vi.mock('../use-workflow-run-callbacks', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + createBaseWorkflowRunCallbacks: vi.fn((params) => { + mocks.mockCreateBaseWorkflowRunCallbacks(params) + return actual.createBaseWorkflowRunCallbacks(params) + }), + createFinalWorkflowRunCallbacks: vi.fn((params) => { + mocks.mockCreateFinalWorkflowRunCallbacks(params) + return actual.createFinalWorkflowRunCallbacks(params) + }), + } +}) + +const createWorkflowStoreState = () => ({ + backupDraft: undefined, + environmentVariables: [{ id: 'env-current', value: 'secret' }], + setBackupDraft: vi.fn((value: unknown) => { + mocks.workflowStoreState.backupDraft = value + }), + setEnvironmentVariables: vi.fn((value: unknown) => { + mocks.workflowStoreState.environmentVariables = value + }), + setWorkflowRunningData: vi.fn(), + setIsListening: vi.fn(), + setShowVariableInspectPanel: vi.fn(), + setListeningTriggerType: vi.fn(), + setListeningTriggerNodeIds: vi.fn(), + setListeningTriggerIsAll: vi.fn(), + setListeningTriggerNodeId: vi.fn(), +}) + +describe('useWorkflowRun', () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '
' + const workflowContainer = document.getElementById('workflow-container')! + Object.defineProperty(workflowContainer, 'clientWidth', { value: 960, configurable: true }) + Object.defineProperty(workflowContainer, 'clientHeight', { value: 540, configurable: true }) + + mocks.reactFlowStoreState.getNodes.mockReturnValue([ + { id: 'node-1', data: { selected: true, _runningStatus: 'running' } }, + ]) + mocks.mockGetViewport.mockReturnValue({ x: 1, y: 2, zoom: 1.5 }) + mocks.mockDoSyncWorkflowDraft.mockResolvedValue(undefined) + mocks.mockPost.mockResolvedValue(new Response('data: ok', { + headers: { 'content-type': 'text/event-stream' }, + })) + mocks.mockGetAudioPlayer.mockReturnValue({ + playAudioWithAudio: vi.fn(), + }) + mocks.workflowStoreState.backupDraft = undefined + Object.assign(mocks.workflowStoreState, createWorkflowStoreState()) + mocks.workflowStoreSetState.mockImplementation((partial: Record) => { + Object.assign(mocks.workflowStoreState, partial) + }) + mocks.featuresStoreState.features = { + file: { + enabled: true, + }, + } + }) + + it('should backup the current draft once and skip subsequent backups until it is cleared', () => { + const { result } = renderHook(() => useWorkflowRun()) + + act(() => { + result.current.handleBackupDraft() + result.current.handleBackupDraft() + }) + + expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledTimes(1) + expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith({ + nodes: [{ id: 'node-1', data: { selected: true, _runningStatus: 'running' } }], + edges: [{ id: 'edge-1' }], + viewport: { x: 1, y: 2, zoom: 1.5 }, + features: { file: { enabled: true } }, + environmentVariables: [{ id: 'env-current', value: 'secret' }], + }) + expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) + }) + + it('should load a backup draft into canvas, environment variables, and features state', () => { + mocks.workflowStoreState.backupDraft = { + nodes: [{ id: 'backup-node' }], + edges: [{ id: 'backup-edge' }], + viewport: { x: 0, y: 0, zoom: 2 }, + features: { opening: { enabled: true } }, + environmentVariables: [{ id: 'env-backup', value: 'value' }], + } + + const { result } = renderHook(() => useWorkflowRun()) + + act(() => { + result.current.handleLoadBackupDraft() + }) + + expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ + nodes: [{ id: 'backup-node' }], + edges: [{ id: 'backup-edge' }], + viewport: { x: 0, y: 0, zoom: 2 }, + }) + expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-backup', value: 'value' }]) + expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({ + features: { opening: { enabled: true } }, + }) + expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith(undefined) + }) + + it('should prepare the graph and dispatch a workflow run through ssePost for user-input mode', async () => { + const { result } = renderHook(() => useWorkflowRun()) + + await act(async () => { + await result.current.handleRun({ inputs: { query: 'hello' } }) + }) + + expect(mocks.reactFlowStoreState.setNodes).toHaveBeenCalledWith([ + { id: 'node-1', data: { selected: false, _runningStatus: undefined } }, + ]) + expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mocks.workflowStoreSetState).toHaveBeenCalledWith({ historyWorkflowData: undefined }) + expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(false) + expect(mocks.workflowStoreState.setListeningTriggerType).toHaveBeenCalledWith(null) + expect(mocks.workflowStoreState.setListeningTriggerNodeId).toHaveBeenCalledWith(null) + expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith([]) + expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(false) + expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({ + result: expect.objectContaining({ + status: WorkflowRunningStatus.Running, + }), + })) + expect(mocks.mockSsePost).toHaveBeenCalledWith( + '/apps/app-1/workflows/draft/run', + { body: { inputs: { query: 'hello' } } }, + expect.objectContaining({ + getAbortController: expect.any(Function), + }), + ) + }) + + it.each([ + { + title: 'schedule', + params: {}, + options: { mode: TriggerType.Schedule, scheduleNodeId: 'schedule-1' }, + expectedUrl: '/apps/app-1/workflows/draft/trigger/run', + expectedBody: { node_id: 'schedule-1' }, + expectedNodeIds: ['schedule-1'], + expectedIsAll: false, + }, + { + title: 'webhook', + params: { node_id: 'webhook-1' }, + options: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' }, + expectedUrl: '/apps/app-1/workflows/draft/trigger/run', + expectedBody: { node_id: 'webhook-1' }, + expectedNodeIds: ['webhook-1'], + expectedIsAll: false, + }, + { + title: 'plugin', + params: { node_id: 'plugin-1' }, + options: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' }, + expectedUrl: '/apps/app-1/workflows/draft/trigger/run', + expectedBody: { node_id: 'plugin-1' }, + expectedNodeIds: ['plugin-1'], + expectedIsAll: false, + }, + { + title: 'all', + params: { node_ids: ['trigger-1', 'trigger-2'] }, + options: { mode: TriggerType.All, allNodeIds: ['trigger-1', 'trigger-2'] }, + expectedUrl: '/apps/app-1/workflows/draft/trigger/run-all', + expectedBody: { node_ids: ['trigger-1', 'trigger-2'] }, + expectedNodeIds: ['trigger-1', 'trigger-2'], + expectedIsAll: true, + }, + ])('should dispatch $title trigger runs through the debug runner integration', async ({ + params, + options, + expectedUrl, + expectedBody, + expectedNodeIds, + expectedIsAll, + }) => { + const { result } = renderHook(() => useWorkflowRun()) + + await act(async () => { + await result.current.handleRun(params, undefined, options) + }) + + expect(mocks.mockPost).toHaveBeenCalledWith( + expectedUrl, + expect.objectContaining({ + body: expectedBody, + signal: expect.any(AbortSignal), + }), + { needAllResponseContent: true }, + ) + expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(true) + expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith(expectedNodeIds) + expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(expectedIsAll) + expect(mocks.mockSsePost).not.toHaveBeenCalled() + }) + + it('should expose the workflow-failed tracker through the callback factory context', async () => { + const { result } = renderHook(() => useWorkflowRun()) + + await act(async () => { + await result.current.handleRun({ inputs: { query: 'hello' } }) + }) + + const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as { + trackWorkflowRunFailed: (params: { error?: string, node_type?: string }) => void + } + + baseCallbackFactoryContext.trackWorkflowRunFailed({ error: 'failed', node_type: 'llm' }) + + expect(mocks.mockTrackEvent).toHaveBeenCalledWith('workflow_run_failed', { + workflow_id: 'flow-1', + reason: 'failed', + node_type: 'llm', + }) + }) + + it('should lazily create audio players with the correct public and private tts urls', async () => { + const { result } = renderHook(() => useWorkflowRun()) + + await act(async () => { + await result.current.handleRun({ token: 'public-token' }) + }) + + const publicBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as { + getOrCreatePlayer: () => unknown + } + + publicBaseCallbackFactoryContext.getOrCreatePlayer() + + expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith( + '/text-to-audio', + true, + expect.any(String), + 'none', + 'none', + expect.any(Function), + ) + + mocks.mockSsePost.mockClear() + mocks.mockGetAudioPlayer.mockClear() + + await act(async () => { + await result.current.handleRun({ appId: 'app-2' }) + }) + + const privateBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as { + getOrCreatePlayer: () => unknown + } + + privateBaseCallbackFactoryContext.getOrCreatePlayer() + + expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith( + '/apps/app-2/text-to-audio', + false, + expect.any(String), + 'none', + 'none', + expect.any(Function), + ) + }) + + it('should stop workflow runs by task id or by aborting active debug controllers', async () => { + const { result } = renderHook(() => useWorkflowRun()) + + await act(async () => { + await result.current.handleRun({ inputs: { query: 'hello' } }) + }) + + act(() => { + result.current.handleStopRun('task-1') + }) + + expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith('/apps/app-1/workflow-runs/tasks/task-1/stop') + expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({ + result: expect.objectContaining({ + status: WorkflowRunningStatus.Stopped, + }), + })) + + const webhookAbort = vi.fn() + const pluginAbort = vi.fn() + const scheduleAbort = vi.fn() + const allTriggersAbort = vi.fn() + const windowWithDebugControllers = window as DebugControllerWindow + windowWithDebugControllers.__webhookDebugAbortController = { abort: webhookAbort } + windowWithDebugControllers.__pluginDebugAbortController = { abort: pluginAbort } + windowWithDebugControllers.__scheduleDebugAbortController = { abort: scheduleAbort } + windowWithDebugControllers.__allTriggersDebugAbortController = { abort: allTriggersAbort } + const refController = new AbortController() + const refAbortSpy = vi.spyOn(refController, 'abort') + const { getAbortController } = mocks.mockSsePost.mock.calls.at(-1)?.[2] as { + getAbortController?: (controller: AbortController) => void + } + getAbortController?.(refController) + + act(() => { + result.current.handleStopRun('') + }) + + expect(webhookAbort).toHaveBeenCalled() + expect(pluginAbort).toHaveBeenCalled() + expect(scheduleAbort).toHaveBeenCalled() + expect(allTriggersAbort).toHaveBeenCalled() + expect(refAbortSpy).toHaveBeenCalled() + }) + + it('should restore published workflow graph, features, and environment variables', () => { + const { result } = renderHook(() => useWorkflowRun()) + + act(() => { + result.current.handleRestoreFromPublishedWorkflow({ + graph: { + nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }], + edges: [{ id: 'published-edge' }], + viewport: { x: 10, y: 20, zoom: 0.8 }, + }, + features: { + opening_statement: 'hello', + suggested_questions: ['Q1'], + suggested_questions_after_answer: { enabled: true }, + text_to_speech: { enabled: true }, + speech_to_text: { enabled: true }, + retriever_resource: { enabled: true }, + sensitive_word_avoidance: { enabled: true }, + file_upload: { enabled: true }, + }, + environment_variables: [{ id: 'env-published', value: 'value' }], + } as never) + }) + + expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ + nodes: [{ id: 'published-node', selected: false, data: { selected: false, label: 'Published' } }], + edges: [{ id: 'published-edge' }], + viewport: { x: 10, y: 20, zoom: 0.8 }, + }) + expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({ + features: expect.objectContaining({ + opening: expect.objectContaining({ + enabled: true, + opening_statement: 'hello', + }), + file: { enabled: true }, + }), + }) + expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-published', value: 'value' }]) + }) + + it('should restore published workflows with empty environment variables as an empty list', () => { + const { result } = renderHook(() => useWorkflowRun()) + + act(() => { + result.current.handleRestoreFromPublishedWorkflow({ + graph: { + nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: { + opening_statement: '', + suggested_questions: [], + suggested_questions_after_answer: { enabled: false }, + text_to_speech: { enabled: false }, + speech_to_text: { enabled: false }, + retriever_resource: { enabled: false }, + sensitive_word_avoidance: { enabled: false }, + file_upload: { enabled: false }, + }, + } as never) + }) + + expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({ + features: expect.objectContaining({ + opening: expect.objectContaining({ enabled: false }), + file: { enabled: false }, + }), + }) + expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx b/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx new file mode 100644 index 0000000000..80b5c6e660 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx @@ -0,0 +1,391 @@ +import { act, renderHook } from '@testing-library/react' +import { TriggerType } from '@/app/components/workflow/header/test-run-menu' +import { + BlockEnum, + WorkflowRunningStatus, +} from '@/app/components/workflow/types' +import { useWorkflowStartRun } from '../use-workflow-start-run' + +const mockGetNodes = vi.fn() +const mockGetFeaturesState = vi.fn() +const mockHandleCancelDebugAndPreviewPanel = vi.fn() +const mockHandleRun = vi.fn() +const mockDoSyncWorkflowDraft = vi.fn() +const mockUseIsChatMode = vi.fn() + +const mockSetShowDebugAndPreviewPanel = vi.fn() +const mockSetShowInputsPanel = vi.fn() +const mockSetShowEnvPanel = vi.fn() +const mockSetShowGlobalVariablePanel = vi.fn() +const mockSetShowChatVariablePanel = vi.fn() +const mockSetListeningTriggerType = vi.fn() +const mockSetListeningTriggerNodeId = vi.fn() +const mockSetListeningTriggerNodeIds = vi.fn() +const mockSetListeningTriggerIsAll = vi.fn() +const mockSetHistoryWorkflowData = vi.fn() + +let workflowStoreState: Record + +vi.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => ({ + getNodes: mockGetNodes, + }), + }), +})) + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeaturesStore: () => ({ + getState: mockGetFeaturesState, + }), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useWorkflowInteractions: () => ({ + handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => workflowStoreState, + }), +})) + +vi.mock('@/app/components/workflow-app/hooks', () => ({ + useIsChatMode: () => mockUseIsChatMode(), + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: mockDoSyncWorkflowDraft, + }), + useWorkflowRun: () => ({ + handleRun: mockHandleRun, + }), +})) + +const createWorkflowStoreState = (overrides: Record = {}) => ({ + workflowRunningData: undefined, + showDebugAndPreviewPanel: false, + setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel, + setShowInputsPanel: mockSetShowInputsPanel, + setShowEnvPanel: mockSetShowEnvPanel, + setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel, + setShowChatVariablePanel: mockSetShowChatVariablePanel, + setListeningTriggerType: mockSetListeningTriggerType, + setListeningTriggerNodeId: mockSetListeningTriggerNodeId, + setListeningTriggerNodeIds: mockSetListeningTriggerNodeIds, + setListeningTriggerIsAll: mockSetListeningTriggerIsAll, + setHistoryWorkflowData: mockSetHistoryWorkflowData, + ...overrides, +}) + +describe('useWorkflowStartRun', () => { + beforeEach(() => { + vi.clearAllMocks() + workflowStoreState = createWorkflowStoreState() + mockGetNodes.mockReturnValue([ + { id: 'start-1', data: { type: BlockEnum.Start, variables: [] } }, + ]) + mockGetFeaturesState.mockReturnValue({ + features: { + file: { + image: { + enabled: false, + }, + }, + }, + }) + mockDoSyncWorkflowDraft.mockResolvedValue(undefined) + mockUseIsChatMode.mockReturnValue(false) + }) + + it('should run the workflow immediately when there are no start variables and no image upload input', async () => { + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await result.current.handleWorkflowStartRunInWorkflow() + }) + + expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) + expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockHandleRun).toHaveBeenCalledWith({ inputs: {}, files: [] }) + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false) + }) + + it('should open the input panel instead of running immediately when start inputs are required', async () => { + mockGetNodes.mockReturnValue([ + { id: 'start-1', data: { type: BlockEnum.Start, variables: [{ name: 'query' }] } }, + ]) + + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await result.current.handleWorkflowStartRunInWorkflow() + }) + + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockHandleRun).not.toHaveBeenCalled() + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true) + }) + + it('should open the input panel when image upload is enabled even without start variables', async () => { + mockGetFeaturesState.mockReturnValue({ + features: { + file: { + image: { + enabled: true, + }, + }, + }, + }) + + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await result.current.handleWorkflowStartRunInWorkflow() + }) + + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockHandleRun).not.toHaveBeenCalled() + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true) + }) + + it('should cancel the current debug panel instead of starting another workflow when one is already open', async () => { + workflowStoreState = createWorkflowStoreState({ + showDebugAndPreviewPanel: true, + }) + + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await result.current.handleWorkflowStartRunInWorkflow() + }) + + expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled() + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockHandleRun).not.toHaveBeenCalled() + }) + + it('should short-circuit workflow start when a run is already in progress', async () => { + workflowStoreState = createWorkflowStoreState({ + workflowRunningData: { + result: { + status: WorkflowRunningStatus.Running, + }, + }, + }) + + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await result.current.handleWorkflowStartRunInWorkflow() + }) + + expect(mockSetShowEnvPanel).not.toHaveBeenCalled() + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockHandleRun).not.toHaveBeenCalled() + }) + + it('should configure schedule trigger runs and execute the workflow with schedule options', async () => { + mockGetNodes.mockReturnValue([ + { id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } }, + ]) + + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1') + }) + + expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) + expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false) + expect(mockSetListeningTriggerType).toHaveBeenCalledWith(BlockEnum.TriggerSchedule) + expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith('schedule-1') + expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['schedule-1']) + expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockHandleRun).toHaveBeenCalledWith( + {}, + undefined, + { + mode: TriggerType.Schedule, + scheduleNodeId: 'schedule-1', + }, + ) + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false) + }) + + it('should cancel schedule trigger execution when the debug panel is already open', async () => { + workflowStoreState = createWorkflowStoreState({ + showDebugAndPreviewPanel: true, + }) + mockGetNodes.mockReturnValue([ + { id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } }, + ]) + + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1') + }) + + expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled() + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockHandleRun).not.toHaveBeenCalled() + }) + + it.each([ + { + title: 'schedule', + invoke: (hook: ReturnType) => hook.handleWorkflowTriggerScheduleRunInWorkflow(undefined), + }, + { + title: 'webhook', + invoke: (hook: ReturnType) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: '' }), + }, + { + title: 'plugin', + invoke: (hook: ReturnType) => hook.handleWorkflowTriggerPluginRunInWorkflow(''), + }, + ])('should ignore $title trigger execution when the node id is empty', async ({ invoke }) => { + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await invoke(result.current) + }) + + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockHandleRun).not.toHaveBeenCalled() + }) + + it.each([ + { + title: 'schedule', + warnMessage: 'handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found', + invoke: (hook: ReturnType) => hook.handleWorkflowTriggerScheduleRunInWorkflow('schedule-missing'), + }, + { + title: 'webhook', + warnMessage: 'handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found', + invoke: (hook: ReturnType) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-missing' }), + }, + { + title: 'plugin', + warnMessage: 'handleWorkflowTriggerPluginRunInWorkflow: plugin node not found', + invoke: (hook: ReturnType) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-missing'), + }, + ])('should warn when the $title trigger node cannot be found', async ({ warnMessage, invoke }) => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockGetNodes.mockReturnValue([{ id: 'other-node', data: { type: BlockEnum.Start } }]) + + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await invoke(result.current) + }) + + expect(consoleWarnSpy).toHaveBeenCalledWith(warnMessage, expect.stringContaining('missing')) + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockHandleRun).not.toHaveBeenCalled() + + consoleWarnSpy.mockRestore() + }) + + it.each([ + { + title: 'webhook', + nodeId: 'webhook-1', + nodeType: BlockEnum.TriggerWebhook, + invoke: (hook: ReturnType) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-1' }), + expectedParams: { node_id: 'webhook-1' }, + expectedOptions: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' }, + }, + { + title: 'plugin', + nodeId: 'plugin-1', + nodeType: BlockEnum.TriggerPlugin, + invoke: (hook: ReturnType) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-1'), + expectedParams: { node_id: 'plugin-1' }, + expectedOptions: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' }, + }, + ])('should configure $title trigger runs with node-specific options', async ({ nodeId, nodeType, invoke, expectedParams, expectedOptions }) => { + mockGetNodes.mockReturnValue([ + { id: nodeId, data: { type: nodeType } }, + ]) + + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await invoke(result.current) + }) + + expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) + expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false) + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false) + expect(mockSetListeningTriggerType).toHaveBeenCalledWith(nodeType) + expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(nodeId) + expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith([nodeId]) + expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockHandleRun).toHaveBeenCalledWith(expectedParams, undefined, expectedOptions) + }) + + it('should run all triggers and mark the listener state as global', async () => { + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await result.current.handleWorkflowRunAllTriggersInWorkflow(['trigger-1', 'trigger-2']) + }) + + expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) + expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false) + expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false) + expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(true) + expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2']) + expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(null) + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalled() + expect(mockHandleRun).toHaveBeenCalledWith( + { node_ids: ['trigger-1', 'trigger-2'] }, + undefined, + { + mode: TriggerType.All, + allNodeIds: ['trigger-1', 'trigger-2'], + }, + ) + }) + + it('should ignore run-all requests when there are no trigger nodes', async () => { + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + await result.current.handleWorkflowRunAllTriggersInWorkflow([]) + }) + + expect(mockSetListeningTriggerIsAll).not.toHaveBeenCalled() + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockHandleRun).not.toHaveBeenCalled() + }) + + it('should route handleStartWorkflowRun to the chatflow path when chat mode is enabled', async () => { + mockUseIsChatMode.mockReturnValue(true) + + const { result } = renderHook(() => useWorkflowStartRun()) + + await act(async () => { + result.current.handleStartWorkflowRun() + }) + + expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) + expect(mockSetShowChatVariablePanel).toHaveBeenCalledWith(false) + expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false) + expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true) + expect(mockSetHistoryWorkflowData).toHaveBeenCalledWith(undefined) + expect(mockHandleRun).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts new file mode 100644 index 0000000000..5d492a3b35 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts @@ -0,0 +1,82 @@ +import { renderHook } from '@testing-library/react' +import { useWorkflowTemplate } from '../use-workflow-template' + +const mockUseIsChatMode = vi.fn() +let generateNewNodeCalls: Array> = [] + +vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({ + useIsChatMode: () => mockUseIsChatMode(), +})) + +vi.mock('@/app/components/workflow/utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + generateNewNode: (args: { id?: string, data: Record, position: Record }) => { + generateNewNodeCalls.push(args) + return { + newNode: { + id: args.id ?? `generated-${generateNewNodeCalls.length}`, + data: args.data, + position: args.position, + }, + } + }, + } +}) + +describe('useWorkflowTemplate', () => { + beforeEach(() => { + vi.clearAllMocks() + generateNewNodeCalls = [] + }) + + it('should return only the start node template in workflow mode', () => { + mockUseIsChatMode.mockReturnValue(false) + + const { result } = renderHook(() => useWorkflowTemplate()) + + expect(result.current.nodes).toHaveLength(1) + expect(result.current.edges).toEqual([]) + expect(generateNewNodeCalls).toHaveLength(1) + }) + + it('should build start, llm, and answer templates with linked edges in chat mode', () => { + mockUseIsChatMode.mockReturnValue(true) + + const { result } = renderHook(() => useWorkflowTemplate()) + + expect(result.current.nodes).toHaveLength(3) + expect(result.current.nodes.map(node => node.id)).toEqual(['generated-1', 'llm', 'answer']) + expect(result.current.edges).toEqual([ + { + id: 'generated-1-llm', + source: 'generated-1', + sourceHandle: 'source', + target: 'llm', + targetHandle: 'target', + }, + { + id: 'llm-answer', + source: 'llm', + sourceHandle: 'source', + target: 'answer', + targetHandle: 'target', + }, + ]) + expect(generateNewNodeCalls).toHaveLength(3) + expect(generateNewNodeCalls[0].data).toMatchObject({ + type: 'start', + title: 'workflow.blocks.start', + }) + expect(generateNewNodeCalls[1].data).toMatchObject({ + type: 'llm', + title: 'workflow.blocks.llm', + }) + expect(generateNewNodeCalls[2].data).toMatchObject({ + type: 'answer', + title: 'workflow.blocks.answer', + answer: '{{#llm.text#}}', + }) + }) +}) diff --git a/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts b/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts new file mode 100644 index 0000000000..a396442002 --- /dev/null +++ b/web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts @@ -0,0 +1,470 @@ +import type AudioPlayer from '@/app/components/base/audio-btn/audio' +import type { IOtherOptions } from '@/service/base' +import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' +import { sseGet } from '@/service/base' + +type ContainerSize = { + clientWidth: number + clientHeight: number +} + +type WorkflowRunEventHandlers = { + handleWorkflowStarted: NonNullable + handleWorkflowFinished: NonNullable + handleWorkflowFailed: () => void + handleWorkflowNodeStarted: (params: Parameters>[0], containerParams: ContainerSize) => void + handleWorkflowNodeFinished: NonNullable + handleWorkflowNodeHumanInputRequired: NonNullable + handleWorkflowNodeHumanInputFormFilled: NonNullable + handleWorkflowNodeHumanInputFormTimeout: NonNullable + handleWorkflowNodeIterationStarted: (params: Parameters>[0], containerParams: ContainerSize) => void + handleWorkflowNodeIterationNext: NonNullable + handleWorkflowNodeIterationFinished: NonNullable + handleWorkflowNodeLoopStarted: (params: Parameters>[0], containerParams: ContainerSize) => void + handleWorkflowNodeLoopNext: NonNullable + handleWorkflowNodeLoopFinished: NonNullable + handleWorkflowNodeRetry: NonNullable + handleWorkflowAgentLog: NonNullable + handleWorkflowTextChunk: NonNullable + handleWorkflowTextReplace: NonNullable + handleWorkflowPaused: () => void +} + +type UserCallbackHandlers = { + onWorkflowStarted?: IOtherOptions['onWorkflowStarted'] + onWorkflowFinished?: IOtherOptions['onWorkflowFinished'] + onNodeStarted?: IOtherOptions['onNodeStarted'] + onNodeFinished?: IOtherOptions['onNodeFinished'] + onIterationStart?: IOtherOptions['onIterationStart'] + onIterationNext?: IOtherOptions['onIterationNext'] + onIterationFinish?: IOtherOptions['onIterationFinish'] + onLoopStart?: IOtherOptions['onLoopStart'] + onLoopNext?: IOtherOptions['onLoopNext'] + onLoopFinish?: IOtherOptions['onLoopFinish'] + onNodeRetry?: IOtherOptions['onNodeRetry'] + onAgentLog?: IOtherOptions['onAgentLog'] + onError?: IOtherOptions['onError'] + onWorkflowPaused?: IOtherOptions['onWorkflowPaused'] + onHumanInputRequired?: IOtherOptions['onHumanInputRequired'] + onHumanInputFormFilled?: IOtherOptions['onHumanInputFormFilled'] + onHumanInputFormTimeout?: IOtherOptions['onHumanInputFormTimeout'] + onCompleted?: IOtherOptions['onCompleted'] +} + +type CallbackContext = { + clientWidth: number + clientHeight: number + runHistoryUrl: string + isInWorkflowDebug: boolean + fetchInspectVars: (params: Record) => void + invalidAllLastRun: () => void + invalidateRunHistory: (url: string) => void + clearAbortController: () => void + clearListeningState: () => void + trackWorkflowRunFailed: (params: unknown) => void + handlers: WorkflowRunEventHandlers + callbacks: UserCallbackHandlers + restCallback: IOtherOptions +} + +type BaseCallbacksContext = CallbackContext & { + getOrCreatePlayer: () => AudioPlayer | null +} + +type FinalCallbacksContext = CallbackContext & { + baseSseOptions: IOtherOptions + player: AudioPlayer | null + setAbortController: (controller: AbortController) => void +} + +export const createBaseWorkflowRunCallbacks = ({ + clientWidth, + clientHeight, + runHistoryUrl, + isInWorkflowDebug, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + clearAbortController, + clearListeningState, + trackWorkflowRunFailed, + handlers, + callbacks, + restCallback, + getOrCreatePlayer, +}: BaseCallbacksContext): IOtherOptions => { + const { + handleWorkflowStarted, + handleWorkflowFinished, + handleWorkflowFailed, + handleWorkflowNodeStarted, + handleWorkflowNodeFinished, + handleWorkflowNodeHumanInputRequired, + handleWorkflowNodeHumanInputFormFilled, + handleWorkflowNodeHumanInputFormTimeout, + handleWorkflowNodeIterationStarted, + handleWorkflowNodeIterationNext, + handleWorkflowNodeIterationFinished, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopFinished, + handleWorkflowNodeRetry, + handleWorkflowAgentLog, + handleWorkflowTextChunk, + handleWorkflowTextReplace, + handleWorkflowPaused, + } = handlers + const { + onWorkflowStarted, + onWorkflowFinished, + onNodeStarted, + onNodeFinished, + onIterationStart, + onIterationNext, + onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, + onNodeRetry, + onAgentLog, + onError, + onWorkflowPaused, + onHumanInputRequired, + onHumanInputFormFilled, + onHumanInputFormTimeout, + onCompleted, + } = callbacks + + const wrappedOnError: IOtherOptions['onError'] = (params, code) => { + clearAbortController() + handleWorkflowFailed() + invalidateRunHistory(runHistoryUrl) + clearListeningState() + + if (onError) + onError(params, code) + + trackWorkflowRunFailed(params) + } + + const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError, errorMessage) => { + clearAbortController() + clearListeningState() + if (onCompleted) + onCompleted(hasError, errorMessage) + } + + const baseSseOptions: IOtherOptions = { + ...restCallback, + onWorkflowStarted: (params) => { + handleWorkflowStarted(params) + invalidateRunHistory(runHistoryUrl) + + if (onWorkflowStarted) + onWorkflowStarted(params) + }, + onWorkflowFinished: (params) => { + clearListeningState() + handleWorkflowFinished(params) + invalidateRunHistory(runHistoryUrl) + + if (onWorkflowFinished) + onWorkflowFinished(params) + if (isInWorkflowDebug) { + fetchInspectVars({}) + invalidAllLastRun() + } + }, + onNodeStarted: (params) => { + handleWorkflowNodeStarted(params, { clientWidth, clientHeight }) + + if (onNodeStarted) + onNodeStarted(params) + }, + onNodeFinished: (params) => { + handleWorkflowNodeFinished(params) + + if (onNodeFinished) + onNodeFinished(params) + }, + onIterationStart: (params) => { + handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight }) + + if (onIterationStart) + onIterationStart(params) + }, + onIterationNext: (params) => { + handleWorkflowNodeIterationNext(params) + + if (onIterationNext) + onIterationNext(params) + }, + onIterationFinish: (params) => { + handleWorkflowNodeIterationFinished(params) + + if (onIterationFinish) + onIterationFinish(params) + }, + onLoopStart: (params) => { + handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight }) + + if (onLoopStart) + onLoopStart(params) + }, + onLoopNext: (params) => { + handleWorkflowNodeLoopNext(params) + + if (onLoopNext) + onLoopNext(params) + }, + onLoopFinish: (params) => { + handleWorkflowNodeLoopFinished(params) + + if (onLoopFinish) + onLoopFinish(params) + }, + onNodeRetry: (params) => { + handleWorkflowNodeRetry(params) + + if (onNodeRetry) + onNodeRetry(params) + }, + onAgentLog: (params) => { + handleWorkflowAgentLog(params) + + if (onAgentLog) + onAgentLog(params) + }, + onTextChunk: (params) => { + handleWorkflowTextChunk(params) + }, + onTextReplace: (params) => { + handleWorkflowTextReplace(params) + }, + onTTSChunk: (messageId: string, audio: string) => { + if (!audio || audio === '') + return + const audioPlayer = getOrCreatePlayer() + if (audioPlayer) { + audioPlayer.playAudioWithAudio(audio, true) + AudioPlayerManager.getInstance().resetMsgId(messageId) + } + }, + onTTSEnd: (_messageId: string, audio: string) => { + const audioPlayer = getOrCreatePlayer() + if (audioPlayer) + audioPlayer.playAudioWithAudio(audio, false) + }, + onWorkflowPaused: (params) => { + handleWorkflowPaused() + invalidateRunHistory(runHistoryUrl) + if (onWorkflowPaused) + onWorkflowPaused(params) + const url = `/workflow/${params.workflow_run_id}/events` + sseGet(url, {}, baseSseOptions) + }, + onHumanInputRequired: (params) => { + handleWorkflowNodeHumanInputRequired(params) + if (onHumanInputRequired) + onHumanInputRequired(params) + }, + onHumanInputFormFilled: (params) => { + handleWorkflowNodeHumanInputFormFilled(params) + if (onHumanInputFormFilled) + onHumanInputFormFilled(params) + }, + onHumanInputFormTimeout: (params) => { + handleWorkflowNodeHumanInputFormTimeout(params) + if (onHumanInputFormTimeout) + onHumanInputFormTimeout(params) + }, + onError: wrappedOnError, + onCompleted: wrappedOnCompleted, + } + + return baseSseOptions +} + +export const createFinalWorkflowRunCallbacks = ({ + clientWidth, + clientHeight, + runHistoryUrl, + isInWorkflowDebug, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + clearAbortController: _clearAbortController, + clearListeningState: _clearListeningState, + trackWorkflowRunFailed: _trackWorkflowRunFailed, + handlers, + callbacks, + restCallback, + baseSseOptions, + player, + setAbortController, +}: FinalCallbacksContext): IOtherOptions => { + const { + handleWorkflowFinished, + handleWorkflowFailed, + handleWorkflowNodeStarted, + handleWorkflowNodeFinished, + handleWorkflowNodeHumanInputRequired, + handleWorkflowNodeHumanInputFormFilled, + handleWorkflowNodeHumanInputFormTimeout, + handleWorkflowNodeIterationStarted, + handleWorkflowNodeIterationNext, + handleWorkflowNodeIterationFinished, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopFinished, + handleWorkflowNodeRetry, + handleWorkflowAgentLog, + handleWorkflowTextChunk, + handleWorkflowTextReplace, + handleWorkflowPaused, + } = handlers + const { + onWorkflowFinished, + onNodeStarted, + onNodeFinished, + onIterationStart, + onIterationNext, + onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, + onNodeRetry, + onAgentLog, + onError, + onWorkflowPaused, + onHumanInputRequired, + onHumanInputFormFilled, + onHumanInputFormTimeout, + } = callbacks + + const finalCallbacks: IOtherOptions = { + ...baseSseOptions, + getAbortController: (controller: AbortController) => { + setAbortController(controller) + }, + onWorkflowFinished: (params) => { + handleWorkflowFinished(params) + invalidateRunHistory(runHistoryUrl) + + if (onWorkflowFinished) + onWorkflowFinished(params) + if (isInWorkflowDebug) { + fetchInspectVars({}) + invalidAllLastRun() + } + }, + onError: (params, code) => { + handleWorkflowFailed() + invalidateRunHistory(runHistoryUrl) + + if (onError) + onError(params, code) + }, + onNodeStarted: (params) => { + handleWorkflowNodeStarted(params, { clientWidth, clientHeight }) + + if (onNodeStarted) + onNodeStarted(params) + }, + onNodeFinished: (params) => { + handleWorkflowNodeFinished(params) + + if (onNodeFinished) + onNodeFinished(params) + }, + onIterationStart: (params) => { + handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight }) + + if (onIterationStart) + onIterationStart(params) + }, + onIterationNext: (params) => { + handleWorkflowNodeIterationNext(params) + + if (onIterationNext) + onIterationNext(params) + }, + onIterationFinish: (params) => { + handleWorkflowNodeIterationFinished(params) + + if (onIterationFinish) + onIterationFinish(params) + }, + onLoopStart: (params) => { + handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight }) + + if (onLoopStart) + onLoopStart(params) + }, + onLoopNext: (params) => { + handleWorkflowNodeLoopNext(params) + + if (onLoopNext) + onLoopNext(params) + }, + onLoopFinish: (params) => { + handleWorkflowNodeLoopFinished(params) + + if (onLoopFinish) + onLoopFinish(params) + }, + onNodeRetry: (params) => { + handleWorkflowNodeRetry(params) + + if (onNodeRetry) + onNodeRetry(params) + }, + onAgentLog: (params) => { + handleWorkflowAgentLog(params) + + if (onAgentLog) + onAgentLog(params) + }, + onTextChunk: (params) => { + handleWorkflowTextChunk(params) + }, + onTextReplace: (params) => { + handleWorkflowTextReplace(params) + }, + onTTSChunk: (messageId: string, audio: string) => { + if (!audio || audio === '') + return + player?.playAudioWithAudio(audio, true) + AudioPlayerManager.getInstance().resetMsgId(messageId) + }, + onTTSEnd: (_messageId: string, audio: string) => { + player?.playAudioWithAudio(audio, false) + }, + onWorkflowPaused: (params) => { + handleWorkflowPaused() + invalidateRunHistory(runHistoryUrl) + if (onWorkflowPaused) + onWorkflowPaused(params) + const url = `/workflow/${params.workflow_run_id}/events` + sseGet(url, {}, finalCallbacks) + }, + onHumanInputRequired: (params) => { + handleWorkflowNodeHumanInputRequired(params) + if (onHumanInputRequired) + onHumanInputRequired(params) + }, + onHumanInputFormFilled: (params) => { + handleWorkflowNodeHumanInputFormFilled(params) + if (onHumanInputFormFilled) + onHumanInputFormFilled(params) + }, + onHumanInputFormTimeout: (params) => { + handleWorkflowNodeHumanInputFormTimeout(params) + if (onHumanInputFormTimeout) + onHumanInputFormTimeout(params) + }, + ...restCallback, + } + + return finalCallbacks +} diff --git a/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts b/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts new file mode 100644 index 0000000000..c2e339d8d9 --- /dev/null +++ b/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts @@ -0,0 +1,443 @@ +import type { Features as FeaturesData } from '@/app/components/base/features/types' +import type { TriggerNodeType } from '@/app/components/workflow/types' +import type { IOtherOptions } from '@/service/base' +import type { VersionHistory } from '@/types/workflow' +import { noop } from 'es-toolkit/function' +import { toast } from '@/app/components/base/ui/toast' +import { TriggerType } from '@/app/components/workflow/header/test-run-menu' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { handleStream, post } from '@/service/base' +import { ContentType } from '@/service/fetch' +import { AppModeEnum } from '@/types/app' + +export type HandleRunMode = TriggerType +export type HandleRunOptions = { + mode?: HandleRunMode + scheduleNodeId?: string + webhookNodeId?: string + pluginNodeId?: string + allNodeIds?: string[] +} + +export type DebuggableTriggerType = Exclude + +type AppDetailLike = { + id?: string + mode?: AppModeEnum +} + +type TTSParamsLike = { + token?: string + appId?: string +} + +type ListeningStateActions = { + setWorkflowRunningData: (data: ReturnType | ReturnType | ReturnType) => void + setIsListening: (value: boolean) => void + setShowVariableInspectPanel: (value: boolean) => void + setListeningTriggerType: (value: TriggerNodeType | null) => void + setListeningTriggerNodeIds: (value: string[]) => void + setListeningTriggerIsAll: (value: boolean) => void + setListeningTriggerNodeId: (value: string | null) => void +} + +type TriggerDebugRunnerOptions = { + debugType: DebuggableTriggerType + url: string + requestBody: unknown + baseSseOptions: IOtherOptions + controllerTarget: Record + setAbortController: (controller: AbortController | null) => void + clearAbortController: () => void + clearListeningState: () => void + setWorkflowRunningData: ListeningStateActions['setWorkflowRunningData'] +} + +export const controllerKeyMap: Record = { + [TriggerType.Webhook]: '__webhookDebugAbortController', + [TriggerType.Plugin]: '__pluginDebugAbortController', + [TriggerType.All]: '__allTriggersDebugAbortController', + [TriggerType.Schedule]: '__scheduleDebugAbortController', +} + +export const debugLabelMap: Record = { + [TriggerType.Webhook]: 'Webhook', + [TriggerType.Plugin]: 'Plugin', + [TriggerType.All]: 'All', + [TriggerType.Schedule]: 'Schedule', +} + +export const createRunningWorkflowState = () => { + return { + result: { + status: WorkflowRunningStatus.Running, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + resultText: '', + } +} + +export const createStoppedWorkflowState = () => { + return { + result: { + status: WorkflowRunningStatus.Stopped, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + resultText: '', + } +} + +export const createFailedWorkflowState = (error: string) => { + return { + result: { + status: WorkflowRunningStatus.Failed, + error, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + } +} + +export const buildRunHistoryUrl = (appDetail?: AppDetailLike) => { + return appDetail?.mode === AppModeEnum.ADVANCED_CHAT + ? `/apps/${appDetail.id}/advanced-chat/workflow-runs` + : `/apps/${appDetail?.id}/workflow-runs` +} + +export const resolveWorkflowRunUrl = ( + appDetail: AppDetailLike | undefined, + runMode: HandleRunMode, + isInWorkflowDebug: boolean, +) => { + if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) { + if (!appDetail?.id) { + console.error('handleRun: missing app id for trigger plugin run') + return '' + } + + return `/apps/${appDetail.id}/workflows/draft/trigger/run` + } + + if (runMode === TriggerType.All) { + if (!appDetail?.id) { + console.error('handleRun: missing app id for trigger run all') + return '' + } + + return `/apps/${appDetail.id}/workflows/draft/trigger/run-all` + } + + if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) + return `/apps/${appDetail.id}/advanced-chat/workflows/draft/run` + + if (isInWorkflowDebug && appDetail?.id) + return `/apps/${appDetail.id}/workflows/draft/run` + + return '' +} + +export const buildWorkflowRunRequestBody = ( + runMode: HandleRunMode, + resolvedParams: Record, + options?: HandleRunOptions, +) => { + if (runMode === TriggerType.Schedule) + return { node_id: options?.scheduleNodeId } + + if (runMode === TriggerType.Webhook) + return { node_id: options?.webhookNodeId } + + if (runMode === TriggerType.Plugin) + return { node_id: options?.pluginNodeId } + + if (runMode === TriggerType.All) + return { node_ids: options?.allNodeIds } + + return resolvedParams +} + +export const validateWorkflowRunRequest = ( + runMode: HandleRunMode, + options?: HandleRunOptions, +) => { + if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) + return 'handleRun: schedule trigger run requires node id' + + if (runMode === TriggerType.Webhook && !options?.webhookNodeId) + return 'handleRun: webhook trigger run requires node id' + + if (runMode === TriggerType.Plugin && !options?.pluginNodeId) + return 'handleRun: plugin trigger run requires node id' + + if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0) + return 'handleRun: all trigger run requires node ids' + + return '' +} + +export const isDebuggableTriggerType = ( + runMode: HandleRunMode, +): runMode is DebuggableTriggerType => { + return ( + runMode === TriggerType.Schedule + || runMode === TriggerType.Webhook + || runMode === TriggerType.Plugin + || runMode === TriggerType.All + ) +} + +export const buildListeningTriggerNodeIds = ( + runMode: DebuggableTriggerType, + options?: HandleRunOptions, +) => { + if (runMode === TriggerType.All) + return options?.allNodeIds ?? [] + + if (runMode === TriggerType.Webhook && options?.webhookNodeId) + return [options.webhookNodeId] + + if (runMode === TriggerType.Schedule && options?.scheduleNodeId) + return [options.scheduleNodeId] + + if (runMode === TriggerType.Plugin && options?.pluginNodeId) + return [options.pluginNodeId] + + return [] +} + +export const applyRunningStateForMode = ( + actions: ListeningStateActions, + runMode: HandleRunMode, + options?: HandleRunOptions, +) => { + if (isDebuggableTriggerType(runMode)) { + actions.setIsListening(true) + actions.setShowVariableInspectPanel(true) + actions.setListeningTriggerIsAll(runMode === TriggerType.All) + actions.setListeningTriggerNodeIds(buildListeningTriggerNodeIds(runMode, options)) + actions.setWorkflowRunningData(createRunningWorkflowState()) + return + } + + actions.setIsListening(false) + actions.setListeningTriggerType(null) + actions.setListeningTriggerNodeId(null) + actions.setListeningTriggerNodeIds([]) + actions.setListeningTriggerIsAll(false) + actions.setWorkflowRunningData(createRunningWorkflowState()) +} + +export const clearListeningState = (actions: Pick) => { + actions.setIsListening(false) + actions.setListeningTriggerType(null) + actions.setListeningTriggerNodeId(null) + actions.setListeningTriggerNodeIds([]) + actions.setListeningTriggerIsAll(false) +} + +export const applyStoppedState = (actions: Pick) => { + actions.setWorkflowRunningData(createStoppedWorkflowState()) + actions.setIsListening(false) + actions.setListeningTriggerType(null) + actions.setListeningTriggerNodeId(null) + actions.setShowVariableInspectPanel(true) +} + +export const clearWindowDebugControllers = (controllerTarget: Record) => { + delete controllerTarget.__webhookDebugAbortController + delete controllerTarget.__pluginDebugAbortController + delete controllerTarget.__scheduleDebugAbortController + delete controllerTarget.__allTriggersDebugAbortController +} + +export const buildTTSConfig = (resolvedParams: TTSParamsLike, pathname: string) => { + let ttsUrl = '' + let ttsIsPublic = false + + if (resolvedParams.token) { + ttsUrl = '/text-to-audio' + ttsIsPublic = true + } + else if (resolvedParams.appId) { + if (pathname.search('explore/installed') > -1) + ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio` + else + ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio` + } + + return { + ttsUrl, + ttsIsPublic, + } +} + +export const mapPublishedWorkflowFeatures = (publishedWorkflow: VersionHistory): FeaturesData => { + return { + opening: { + enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length, + opening_statement: publishedWorkflow.features.opening_statement, + suggested_questions: publishedWorkflow.features.suggested_questions, + }, + suggested: publishedWorkflow.features.suggested_questions_after_answer, + text2speech: publishedWorkflow.features.text_to_speech, + speech2text: publishedWorkflow.features.speech_to_text, + citation: publishedWorkflow.features.retriever_resource, + moderation: publishedWorkflow.features.sensitive_word_avoidance, + file: publishedWorkflow.features.file_upload, + } +} + +export const normalizePublishedWorkflowNodes = (publishedWorkflow: VersionHistory) => { + return publishedWorkflow.graph.nodes.map(node => ({ + ...node, + selected: false, + data: { + ...node.data, + selected: false, + }, + })) +} + +export const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise((resolve) => { + const timer = window.setTimeout(resolve, delay) + signal.addEventListener('abort', () => { + clearTimeout(timer) + resolve() + }, { once: true }) +}) + +export const runTriggerDebug = async ({ + debugType, + url, + requestBody, + baseSseOptions, + controllerTarget, + setAbortController, + clearAbortController, + clearListeningState, + setWorkflowRunningData, +}: TriggerDebugRunnerOptions) => { + const controller = new AbortController() + setAbortController(controller) + + const controllerKey = controllerKeyMap[debugType] + controllerTarget[controllerKey] = controller + + const debugLabel = debugLabelMap[debugType] + + const poll = async (): Promise => { + try { + const response = await post(url, { + body: requestBody, + signal: controller.signal, + }, { + needAllResponseContent: true, + }) + + if (controller.signal.aborted) + return + + if (!response) { + const message = `${debugLabel} debug request failed` + toast.error(message) + clearAbortController() + return + } + + const contentType = response.headers.get('content-type') || '' + + if (contentType.includes(ContentType.json)) { + let data: Record | null = null + try { + data = await response.json() as Record + } + catch (jsonError) { + console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError) + toast.error(`${debugLabel} debug request failed`) + clearAbortController() + clearListeningState() + return + } + + if (controller.signal.aborted) + return + + if (data?.status === 'waiting') { + const delay = Number(data.retry_in) || 2000 + await waitWithAbort(controller.signal, delay) + if (controller.signal.aborted) + return + await poll() + return + } + + const errorMessage = typeof data?.message === 'string' ? data.message : `${debugLabel} debug failed` + toast.error(errorMessage) + clearAbortController() + setWorkflowRunningData(createFailedWorkflowState(errorMessage)) + clearListeningState() + return + } + + clearListeningState() + handleStream( + response, + baseSseOptions.onData ?? noop, + baseSseOptions.onCompleted, + baseSseOptions.onThought, + baseSseOptions.onMessageEnd, + baseSseOptions.onMessageReplace, + baseSseOptions.onFile, + baseSseOptions.onWorkflowStarted, + baseSseOptions.onWorkflowFinished, + baseSseOptions.onNodeStarted, + baseSseOptions.onNodeFinished, + baseSseOptions.onIterationStart, + baseSseOptions.onIterationNext, + baseSseOptions.onIterationFinish, + baseSseOptions.onLoopStart, + baseSseOptions.onLoopNext, + baseSseOptions.onLoopFinish, + baseSseOptions.onNodeRetry, + baseSseOptions.onParallelBranchStarted, + baseSseOptions.onParallelBranchFinished, + baseSseOptions.onTextChunk, + baseSseOptions.onTTSChunk, + baseSseOptions.onTTSEnd, + baseSseOptions.onTextReplace, + baseSseOptions.onAgentLog, + baseSseOptions.onHumanInputRequired, + baseSseOptions.onHumanInputFormFilled, + baseSseOptions.onHumanInputFormTimeout, + baseSseOptions.onWorkflowPaused, + baseSseOptions.onDataSourceNodeProcessing, + baseSseOptions.onDataSourceNodeCompleted, + baseSseOptions.onDataSourceNodeError, + ) + } + catch (error) { + if (controller.signal.aborted) + return + + if (error instanceof Response) { + const data = await error.clone().json() as Record + const errorMessage = typeof data?.error === 'string' ? data.error : '' + toast.error(errorMessage) + clearAbortController() + setWorkflowRunningData(createFailedWorkflowState(errorMessage)) + } + + clearListeningState() + } + } + + await poll() +} diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index de110f2525..eb32c68049 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -1,3 +1,4 @@ +import type { HandleRunOptions } from './use-workflow-run-utils' import type AudioPlayer from '@/app/components/base/audio-btn/audio' import type { Node } from '@/app/components/workflow/types' import type { IOtherOptions } from '@/service/base' @@ -14,46 +15,38 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' import { useFeaturesStore } from '@/app/components/base/features/hooks' -import Toast from '@/app/components/base/toast' import { TriggerType } from '@/app/components/workflow/header/test-run-menu' import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' import { useWorkflowStore } from '@/app/components/workflow/store' -import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { usePathname } from '@/next/navigation' -import { handleStream, post, sseGet, ssePost } from '@/service/base' -import { ContentType } from '@/service/fetch' +import { ssePost } from '@/service/base' import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow' import { stopWorkflowRun } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars' import { useConfigsMap } from './use-configs-map' import { useNodesSyncDraft } from './use-nodes-sync-draft' +import { + createBaseWorkflowRunCallbacks, + createFinalWorkflowRunCallbacks, +} from './use-workflow-run-callbacks' +import { + applyRunningStateForMode, + applyStoppedState, + buildRunHistoryUrl, + buildTTSConfig, + buildWorkflowRunRequestBody, + clearListeningState, + clearWindowDebugControllers, -type HandleRunMode = TriggerType -type HandleRunOptions = { - mode?: HandleRunMode - scheduleNodeId?: string - webhookNodeId?: string - pluginNodeId?: string - allNodeIds?: string[] -} - -type DebuggableTriggerType = Exclude - -const controllerKeyMap: Record = { - [TriggerType.Webhook]: '__webhookDebugAbortController', - [TriggerType.Plugin]: '__pluginDebugAbortController', - [TriggerType.All]: '__allTriggersDebugAbortController', - [TriggerType.Schedule]: '__scheduleDebugAbortController', -} - -const debugLabelMap: Record = { - [TriggerType.Webhook]: 'Webhook', - [TriggerType.Plugin]: 'Plugin', - [TriggerType.All]: 'All', - [TriggerType.Schedule]: 'Schedule', -} + isDebuggableTriggerType, + mapPublishedWorkflowFeatures, + normalizePublishedWorkflowNodes, + resolveWorkflowRunUrl, + runTriggerDebug, + validateWorkflowRunRequest, +} from './use-workflow-run-utils' export const useWorkflowRun = () => { const store = useStoreApi() @@ -152,7 +145,7 @@ export const useWorkflowRun = () => { callback?: IOtherOptions, options?: HandleRunOptions, ) => { - const runMode: HandleRunMode = options?.mode ?? TriggerType.UserInput + const runMode = options?.mode ?? TriggerType.UserInput const resolvedParams = params ?? {} const { getNodes, @@ -190,9 +183,7 @@ export const useWorkflowRun = () => { } = callback || {} workflowStore.setState({ historyWorkflowData: undefined }) const appDetail = useAppStore.getState().appDetail - const runHistoryUrl = appDetail?.mode === AppModeEnum.ADVANCED_CHAT - ? `/apps/${appDetail.id}/advanced-chat/workflow-runs` - : `/apps/${appDetail?.id}/workflow-runs` + const runHistoryUrl = buildRunHistoryUrl(appDetail) const workflowContainer = document.getElementById('workflow-container') const { @@ -202,65 +193,15 @@ export const useWorkflowRun = () => { const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW - let url = '' - if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) { - if (!appDetail?.id) { - console.error('handleRun: missing app id for trigger plugin run') - return - } - url = `/apps/${appDetail.id}/workflows/draft/trigger/run` - } - else if (runMode === TriggerType.All) { - if (!appDetail?.id) { - console.error('handleRun: missing app id for trigger run all') - return - } - url = `/apps/${appDetail.id}/workflows/draft/trigger/run-all` - } - else if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { - url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run` - } - else if (isInWorkflowDebug && appDetail?.id) { - url = `/apps/${appDetail.id}/workflows/draft/run` - } - - let requestBody = {} - - if (runMode === TriggerType.Schedule) - requestBody = { node_id: options?.scheduleNodeId } - - else if (runMode === TriggerType.Webhook) - requestBody = { node_id: options?.webhookNodeId } - - else if (runMode === TriggerType.Plugin) - requestBody = { node_id: options?.pluginNodeId } - - else if (runMode === TriggerType.All) - requestBody = { node_ids: options?.allNodeIds } - - else - requestBody = resolvedParams + const url = resolveWorkflowRunUrl(appDetail, runMode, isInWorkflowDebug) + const requestBody = buildWorkflowRunRequestBody(runMode, resolvedParams, options) if (!url) return - if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) { - console.error('handleRun: schedule trigger run requires node id') - return - } - - if (runMode === TriggerType.Webhook && !options?.webhookNodeId) { - console.error('handleRun: webhook trigger run requires node id') - return - } - - if (runMode === TriggerType.Plugin && !options?.pluginNodeId) { - console.error('handleRun: plugin trigger run requires node id') - return - } - - if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0) { - console.error('handleRun: all trigger run requires node ids') + const validationMessage = validateWorkflowRunRequest(runMode, options) + if (validationMessage) { + console.error(validationMessage) return } @@ -277,66 +218,17 @@ export const useWorkflowRun = () => { setListeningTriggerNodeId, } = workflowStore.getState() - if ( - runMode === TriggerType.Webhook - || runMode === TriggerType.Plugin - || runMode === TriggerType.All - || runMode === TriggerType.Schedule - ) { - setIsListening(true) - setShowVariableInspectPanel(true) - setListeningTriggerIsAll(runMode === TriggerType.All) - if (runMode === TriggerType.All) - setListeningTriggerNodeIds(options?.allNodeIds ?? []) - else if (runMode === TriggerType.Webhook && options?.webhookNodeId) - setListeningTriggerNodeIds([options.webhookNodeId]) - else if (runMode === TriggerType.Schedule && options?.scheduleNodeId) - setListeningTriggerNodeIds([options.scheduleNodeId]) - else if (runMode === TriggerType.Plugin && options?.pluginNodeId) - setListeningTriggerNodeIds([options.pluginNodeId]) - else - setListeningTriggerNodeIds([]) - setWorkflowRunningData({ - result: { - status: WorkflowRunningStatus.Running, - inputs_truncated: false, - process_data_truncated: false, - outputs_truncated: false, - }, - tracing: [], - resultText: '', - }) - } - else { - setIsListening(false) - setListeningTriggerType(null) - setListeningTriggerNodeId(null) - setListeningTriggerNodeIds([]) - setListeningTriggerIsAll(false) - setWorkflowRunningData({ - result: { - status: WorkflowRunningStatus.Running, - inputs_truncated: false, - process_data_truncated: false, - outputs_truncated: false, - }, - tracing: [], - resultText: '', - }) - } + applyRunningStateForMode({ + setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + setListeningTriggerNodeId, + }, runMode, options) - let ttsUrl = '' - let ttsIsPublic = false - if (resolvedParams.token) { - ttsUrl = '/text-to-audio' - ttsIsPublic = true - } - else if (resolvedParams.appId) { - if (pathname.search('explore/installed') > -1) - ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio` - else - ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio` - } + const { ttsUrl, ttsIsPublic } = buildTTSConfig(resolvedParams, pathname) // Lazy initialization: Only create AudioPlayer when TTS is actually needed // This prevents opening audio channel unnecessarily let player: AudioPlayer | null = null @@ -349,497 +241,121 @@ export const useWorkflowRun = () => { const clearAbortController = () => { abortControllerRef.current = null - delete (window as any).__webhookDebugAbortController - delete (window as any).__pluginDebugAbortController - delete (window as any).__scheduleDebugAbortController - delete (window as any).__allTriggersDebugAbortController + clearWindowDebugControllers(window as unknown as Record) } - const clearListeningState = () => { + const clearListeningStateInStore = () => { const state = workflowStore.getState() - state.setIsListening(false) - state.setListeningTriggerType(null) - state.setListeningTriggerNodeId(null) - state.setListeningTriggerNodeIds([]) - state.setListeningTriggerIsAll(false) + clearListeningState({ + setIsListening: state.setIsListening, + setListeningTriggerType: state.setListeningTriggerType, + setListeningTriggerNodeId: state.setListeningTriggerNodeId, + setListeningTriggerNodeIds: state.setListeningTriggerNodeIds, + setListeningTriggerIsAll: state.setListeningTriggerIsAll, + }) } - const wrappedOnError = (params: any) => { - clearAbortController() - handleWorkflowFailed() - invalidateRunHistory(runHistoryUrl) - clearListeningState() - - if (onError) - onError(params) - trackEvent('workflow_run_failed', { workflow_id: flowId, reason: params.error, node_type: params.node_type }) + const workflowRunEventHandlers = { + handleWorkflowStarted, + handleWorkflowFinished, + handleWorkflowFailed, + handleWorkflowNodeStarted, + handleWorkflowNodeFinished, + handleWorkflowNodeHumanInputRequired, + handleWorkflowNodeHumanInputFormFilled, + handleWorkflowNodeHumanInputFormTimeout, + handleWorkflowNodeIterationStarted, + handleWorkflowNodeIterationNext, + handleWorkflowNodeIterationFinished, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopFinished, + handleWorkflowNodeRetry, + handleWorkflowAgentLog, + handleWorkflowTextChunk, + handleWorkflowTextReplace, + handleWorkflowPaused, + } + const userCallbacks = { + onWorkflowStarted, + onWorkflowFinished, + onNodeStarted, + onNodeFinished, + onIterationStart, + onIterationNext, + onIterationFinish, + onLoopStart, + onLoopNext, + onLoopFinish, + onNodeRetry, + onAgentLog, + onError, + onWorkflowPaused, + onHumanInputRequired, + onHumanInputFormFilled, + onHumanInputFormTimeout, + onCompleted, } - const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError?: boolean, errorMessage?: string) => { - clearAbortController() - clearListeningState() - if (onCompleted) - onCompleted(hasError, errorMessage) + const trackWorkflowRunFailed = (eventParams: unknown) => { + const payload = eventParams as { error?: string, node_type?: string } + trackEvent('workflow_run_failed', { workflow_id: flowId, reason: payload?.error, node_type: payload?.node_type }) } - const baseSseOptions: IOtherOptions = { - ...restCallback, - onWorkflowStarted: (params) => { - handleWorkflowStarted(params) - invalidateRunHistory(runHistoryUrl) - - if (onWorkflowStarted) - onWorkflowStarted(params) - }, - onWorkflowFinished: (params) => { - clearListeningState() - handleWorkflowFinished(params) - invalidateRunHistory(runHistoryUrl) - - if (onWorkflowFinished) - onWorkflowFinished(params) - if (isInWorkflowDebug) { - fetchInspectVars({}) - invalidAllLastRun() - } - }, - onNodeStarted: (params) => { - handleWorkflowNodeStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onNodeStarted) - onNodeStarted(params) - }, - onNodeFinished: (params) => { - handleWorkflowNodeFinished(params) - - if (onNodeFinished) - onNodeFinished(params) - }, - onIterationStart: (params) => { - handleWorkflowNodeIterationStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onIterationStart) - onIterationStart(params) - }, - onIterationNext: (params) => { - handleWorkflowNodeIterationNext(params) - - if (onIterationNext) - onIterationNext(params) - }, - onIterationFinish: (params) => { - handleWorkflowNodeIterationFinished(params) - - if (onIterationFinish) - onIterationFinish(params) - }, - onLoopStart: (params) => { - handleWorkflowNodeLoopStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onLoopStart) - onLoopStart(params) - }, - onLoopNext: (params) => { - handleWorkflowNodeLoopNext(params) - - if (onLoopNext) - onLoopNext(params) - }, - onLoopFinish: (params) => { - handleWorkflowNodeLoopFinished(params) - - if (onLoopFinish) - onLoopFinish(params) - }, - onNodeRetry: (params) => { - handleWorkflowNodeRetry(params) - - if (onNodeRetry) - onNodeRetry(params) - }, - onAgentLog: (params) => { - handleWorkflowAgentLog(params) - - if (onAgentLog) - onAgentLog(params) - }, - onTextChunk: (params) => { - handleWorkflowTextChunk(params) - }, - onTextReplace: (params) => { - handleWorkflowTextReplace(params) - }, - onTTSChunk: (messageId: string, audio: string) => { - if (!audio || audio === '') - return - const audioPlayer = getOrCreatePlayer() - if (audioPlayer) { - audioPlayer.playAudioWithAudio(audio, true) - AudioPlayerManager.getInstance().resetMsgId(messageId) - } - }, - onTTSEnd: (messageId: string, audio: string) => { - const audioPlayer = getOrCreatePlayer() - if (audioPlayer) - audioPlayer.playAudioWithAudio(audio, false) - }, - onWorkflowPaused: (params) => { - handleWorkflowPaused() - invalidateRunHistory(runHistoryUrl) - if (onWorkflowPaused) - onWorkflowPaused(params) - const url = `/workflow/${params.workflow_run_id}/events` - sseGet( - url, - {}, - baseSseOptions, - ) - }, - onHumanInputRequired: (params) => { - handleWorkflowNodeHumanInputRequired(params) - if (onHumanInputRequired) - onHumanInputRequired(params) - }, - onHumanInputFormFilled: (params) => { - handleWorkflowNodeHumanInputFormFilled(params) - if (onHumanInputFormFilled) - onHumanInputFormFilled(params) - }, - onHumanInputFormTimeout: (params) => { - handleWorkflowNodeHumanInputFormTimeout(params) - if (onHumanInputFormTimeout) - onHumanInputFormTimeout(params) - }, - onError: wrappedOnError, - onCompleted: wrappedOnCompleted, - } - - const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise((resolve) => { - const timer = window.setTimeout(resolve, delay) - signal.addEventListener('abort', () => { - clearTimeout(timer) - resolve() - }, { once: true }) + const baseSseOptions = createBaseWorkflowRunCallbacks({ + clientWidth, + clientHeight, + runHistoryUrl, + isInWorkflowDebug, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + clearAbortController, + clearListeningState: clearListeningStateInStore, + trackWorkflowRunFailed, + handlers: workflowRunEventHandlers, + callbacks: userCallbacks, + restCallback, + getOrCreatePlayer, }) - const runTriggerDebug = async (debugType: DebuggableTriggerType) => { - const controller = new AbortController() - abortControllerRef.current = controller - - const controllerKey = controllerKeyMap[debugType] - - ; (window as any)[controllerKey] = controller - - const debugLabel = debugLabelMap[debugType] - - const poll = async (): Promise => { - try { - const response = await post(url, { - body: requestBody, - signal: controller.signal, - }, { - needAllResponseContent: true, - }) - - if (controller.signal.aborted) - return - - if (!response) { - const message = `${debugLabel} debug request failed` - Toast.notify({ type: 'error', message }) - clearAbortController() - return - } - - const contentType = response.headers.get('content-type') || '' - - if (contentType.includes(ContentType.json)) { - let data: any = null - try { - data = await response.json() - } - catch (jsonError) { - console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError) - Toast.notify({ type: 'error', message: `${debugLabel} debug request failed` }) - clearAbortController() - clearListeningState() - return - } - - if (controller.signal.aborted) - return - - if (data?.status === 'waiting') { - const delay = Number(data.retry_in) || 2000 - await waitWithAbort(controller.signal, delay) - if (controller.signal.aborted) - return - await poll() - return - } - - const errorMessage = data?.message || `${debugLabel} debug failed` - Toast.notify({ type: 'error', message: errorMessage }) - clearAbortController() - setWorkflowRunningData({ - result: { - status: WorkflowRunningStatus.Failed, - error: errorMessage, - inputs_truncated: false, - process_data_truncated: false, - outputs_truncated: false, - }, - tracing: [], - }) - clearListeningState() - return - } - - clearListeningState() - handleStream( - response, - baseSseOptions.onData ?? noop, - baseSseOptions.onCompleted, - baseSseOptions.onThought, - baseSseOptions.onMessageEnd, - baseSseOptions.onMessageReplace, - baseSseOptions.onFile, - baseSseOptions.onWorkflowStarted, - baseSseOptions.onWorkflowFinished, - baseSseOptions.onNodeStarted, - baseSseOptions.onNodeFinished, - baseSseOptions.onIterationStart, - baseSseOptions.onIterationNext, - baseSseOptions.onIterationFinish, - baseSseOptions.onLoopStart, - baseSseOptions.onLoopNext, - baseSseOptions.onLoopFinish, - baseSseOptions.onNodeRetry, - baseSseOptions.onParallelBranchStarted, - baseSseOptions.onParallelBranchFinished, - baseSseOptions.onTextChunk, - baseSseOptions.onTTSChunk, - baseSseOptions.onTTSEnd, - baseSseOptions.onTextReplace, - baseSseOptions.onAgentLog, - baseSseOptions.onHumanInputRequired, - baseSseOptions.onHumanInputFormFilled, - baseSseOptions.onHumanInputFormTimeout, - baseSseOptions.onWorkflowPaused, - baseSseOptions.onDataSourceNodeProcessing, - baseSseOptions.onDataSourceNodeCompleted, - baseSseOptions.onDataSourceNodeError, - ) - } - catch (error) { - if (controller.signal.aborted) - return - if (error instanceof Response) { - const data = await error.clone().json() as Record - const { error: respError } = data || {} - Toast.notify({ type: 'error', message: respError }) - clearAbortController() - setWorkflowRunningData({ - result: { - status: WorkflowRunningStatus.Failed, - error: respError, - inputs_truncated: false, - process_data_truncated: false, - outputs_truncated: false, - }, - tracing: [], - }) - } - clearListeningState() - } - } - - await poll() - } - - if (runMode === TriggerType.Schedule) { - await runTriggerDebug(TriggerType.Schedule) + if (isDebuggableTriggerType(runMode)) { + await runTriggerDebug({ + debugType: runMode, + url, + requestBody, + baseSseOptions, + controllerTarget: window as unknown as Record, + setAbortController: (controller) => { + abortControllerRef.current = controller + }, + clearAbortController, + clearListeningState: clearListeningStateInStore, + setWorkflowRunningData, + }) return } - if (runMode === TriggerType.Webhook) { - await runTriggerDebug(TriggerType.Webhook) - return - } - - if (runMode === TriggerType.Plugin) { - await runTriggerDebug(TriggerType.Plugin) - return - } - - if (runMode === TriggerType.All) { - await runTriggerDebug(TriggerType.All) - return - } - - const finalCallbacks: IOtherOptions = { - ...baseSseOptions, - getAbortController: (controller: AbortController) => { + const finalCallbacks = createFinalWorkflowRunCallbacks({ + clientWidth, + clientHeight, + runHistoryUrl, + isInWorkflowDebug, + fetchInspectVars, + invalidAllLastRun, + invalidateRunHistory, + clearAbortController, + clearListeningState: clearListeningStateInStore, + trackWorkflowRunFailed, + handlers: workflowRunEventHandlers, + callbacks: userCallbacks, + restCallback, + baseSseOptions, + player, + setAbortController: (controller) => { abortControllerRef.current = controller }, - onWorkflowFinished: (params) => { - handleWorkflowFinished(params) - invalidateRunHistory(runHistoryUrl) - - if (onWorkflowFinished) - onWorkflowFinished(params) - if (isInWorkflowDebug) { - fetchInspectVars({}) - invalidAllLastRun() - } - }, - onError: (params) => { - handleWorkflowFailed() - invalidateRunHistory(runHistoryUrl) - - if (onError) - onError(params) - }, - onNodeStarted: (params) => { - handleWorkflowNodeStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onNodeStarted) - onNodeStarted(params) - }, - onNodeFinished: (params) => { - handleWorkflowNodeFinished(params) - - if (onNodeFinished) - onNodeFinished(params) - }, - onIterationStart: (params) => { - handleWorkflowNodeIterationStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onIterationStart) - onIterationStart(params) - }, - onIterationNext: (params) => { - handleWorkflowNodeIterationNext(params) - - if (onIterationNext) - onIterationNext(params) - }, - onIterationFinish: (params) => { - handleWorkflowNodeIterationFinished(params) - - if (onIterationFinish) - onIterationFinish(params) - }, - onLoopStart: (params) => { - handleWorkflowNodeLoopStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onLoopStart) - onLoopStart(params) - }, - onLoopNext: (params) => { - handleWorkflowNodeLoopNext(params) - - if (onLoopNext) - onLoopNext(params) - }, - onLoopFinish: (params) => { - handleWorkflowNodeLoopFinished(params) - - if (onLoopFinish) - onLoopFinish(params) - }, - onNodeRetry: (params) => { - handleWorkflowNodeRetry(params) - - if (onNodeRetry) - onNodeRetry(params) - }, - onAgentLog: (params) => { - handleWorkflowAgentLog(params) - - if (onAgentLog) - onAgentLog(params) - }, - onTextChunk: (params) => { - handleWorkflowTextChunk(params) - }, - onTextReplace: (params) => { - handleWorkflowTextReplace(params) - }, - onTTSChunk: (messageId: string, audio: string) => { - if (!audio || audio === '') - return - player?.playAudioWithAudio(audio, true) - AudioPlayerManager.getInstance().resetMsgId(messageId) - }, - onTTSEnd: (messageId: string, audio: string) => { - player?.playAudioWithAudio(audio, false) - }, - onWorkflowPaused: (params) => { - handleWorkflowPaused() - invalidateRunHistory(runHistoryUrl) - if (onWorkflowPaused) - onWorkflowPaused(params) - const url = `/workflow/${params.workflow_run_id}/events` - sseGet( - url, - {}, - finalCallbacks, - ) - }, - onHumanInputRequired: (params) => { - handleWorkflowNodeHumanInputRequired(params) - if (onHumanInputRequired) - onHumanInputRequired(params) - }, - onHumanInputFormFilled: (params) => { - handleWorkflowNodeHumanInputFormFilled(params) - if (onHumanInputFormFilled) - onHumanInputFormFilled(params) - }, - onHumanInputFormTimeout: (params) => { - handleWorkflowNodeHumanInputFormTimeout(params) - if (onHumanInputFormTimeout) - onHumanInputFormTimeout(params) - }, - ...restCallback, - } + }) ssePost( url, @@ -860,20 +376,13 @@ export const useWorkflowRun = () => { setListeningTriggerNodeId, } = workflowStore.getState() - setWorkflowRunningData({ - result: { - status: WorkflowRunningStatus.Stopped, - inputs_truncated: false, - process_data_truncated: false, - outputs_truncated: false, - }, - tracing: [], - resultText: '', + applyStoppedState({ + setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeId, }) - setIsListening(false) - setListeningTriggerType(null) - setListeningTriggerNodeId(null) - setShowVariableInspectPanel(true) } if (taskId) { @@ -909,7 +418,7 @@ export const useWorkflowRun = () => { }, [workflowStore]) const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => { - const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } })) + const nodes = normalizePublishedWorkflowNodes(publishedWorkflow) const edges = publishedWorkflow.graph.edges const viewport = publishedWorkflow.graph.viewport! handleUpdateWorkflowCanvas({ @@ -917,21 +426,7 @@ export const useWorkflowRun = () => { edges, viewport, }) - const mappedFeatures = { - opening: { - enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length, - opening_statement: publishedWorkflow.features.opening_statement, - suggested_questions: publishedWorkflow.features.suggested_questions, - }, - suggested: publishedWorkflow.features.suggested_questions_after_answer, - text2speech: publishedWorkflow.features.text_to_speech, - speech2text: publishedWorkflow.features.speech_to_text, - citation: publishedWorkflow.features.retriever_resource, - moderation: publishedWorkflow.features.sensitive_word_avoidance, - file: publishedWorkflow.features.file_upload, - } - - featuresStore?.setState({ features: mappedFeatures }) + featuresStore?.setState({ features: mapPublishedWorkflowFeatures(publishedWorkflow) }) workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || []) }, [featuresStore, handleUpdateWorkflowCanvas, workflowStore]) diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 0e8731869f..57bff3fa6e 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -9,16 +9,12 @@ import { import { useStore as useAppStore } from '@/app/components/app/store' import { FeaturesProvider } from '@/app/components/base/features' import Loading from '@/app/components/base/loading' -import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import WorkflowWithDefaultContext from '@/app/components/workflow' import { WorkflowContextProvider, } from '@/app/components/workflow/context' import { useWorkflowStore } from '@/app/components/workflow/store' import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' -import { - SupportUploadFileTypes, -} from '@/app/components/workflow/types' import { initialEdges, initialNodes, @@ -35,6 +31,11 @@ import { useWorkflowInit, } from './hooks/use-workflow-init' import { createWorkflowSlice } from './store/workflow/workflow-slice' +import { + buildInitialFeatures, + buildTriggerStatusMap, + coerceReplayUserInputs, +} from './utils' const WorkflowAppWithAdditionalContext = () => { const { @@ -58,13 +59,7 @@ const WorkflowAppWithAdditionalContext = () => { // Sync trigger statuses to store when data loads useEffect(() => { if (triggersResponse?.data) { - // Map API status to EntryNodeStatus: 'enabled' stays 'enabled', all others become 'disabled' - const statusMap = triggersResponse.data.reduce((acc, trigger) => { - acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' - return acc - }, {} as Record) - - setTriggerStatuses(statusMap) + setTriggerStatuses(buildTriggerStatusMap(triggersResponse.data)) } }, [triggersResponse?.data, setTriggerStatuses]) @@ -108,49 +103,21 @@ const WorkflowAppWithAdditionalContext = () => { fetchRunDetail(runUrl).then((res) => { const { setInputs, setShowInputsPanel, setShowDebugAndPreviewPanel } = workflowStore.getState() const rawInputs = res.inputs - let parsedInputs: Record | null = null + let parsedInputs: unknown = rawInputs if (typeof rawInputs === 'string') { try { - const maybeParsed = JSON.parse(rawInputs) as unknown - if (maybeParsed && typeof maybeParsed === 'object' && !Array.isArray(maybeParsed)) - parsedInputs = maybeParsed as Record + parsedInputs = JSON.parse(rawInputs) as unknown } catch (error) { console.error('Failed to parse workflow run inputs', error) + return } } - else if (rawInputs && typeof rawInputs === 'object' && !Array.isArray(rawInputs)) { - parsedInputs = rawInputs as Record - } - if (!parsedInputs) - return + const userInputs = coerceReplayUserInputs(parsedInputs) - const userInputs: Record = {} - Object.entries(parsedInputs).forEach(([key, value]) => { - if (key.startsWith('sys.')) - return - - if (value == null) { - userInputs[key] = '' - return - } - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - userInputs[key] = value - return - } - - try { - userInputs[key] = JSON.stringify(value) - } - catch { - userInputs[key] = String(value) - } - }) - - if (!Object.keys(userInputs).length) + if (!userInputs || !Object.keys(userInputs).length) return setInputs(userInputs) @@ -167,32 +134,7 @@ const WorkflowAppWithAdditionalContext = () => { ) } - const features = data.features || {} - const initialFeatures: FeaturesData = { - file: { - image: { - enabled: !!features.file_upload?.image?.enabled, - number_limits: features.file_upload?.image?.number_limits || 3, - transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], - }, - enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled), - allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`), - allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], - number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3, - fileUploadConfig: fileUploadConfigResponse, - }, - opening: { - enabled: !!features.opening_statement, - opening_statement: features.opening_statement, - suggested_questions: features.suggested_questions, - }, - suggested: features.suggested_questions_after_answer || { enabled: false }, - speech2text: features.speech_to_text || { enabled: false }, - text2speech: features.text_to_speech || { enabled: false }, - citation: features.retriever_resource || { enabled: false }, - moderation: features.sensitive_word_avoidance || { enabled: false }, - } + const initialFeatures: FeaturesData = buildInitialFeatures(data.features, fileUploadConfigResponse) return ( { + it('should initialize workflow slice state with expected defaults', () => { + const store = createStore(createWorkflowSlice) + const state = store.getState() + + expect(state.appId).toBe('') + expect(state.appName).toBe('') + expect(state.notInitialWorkflow).toBe(false) + expect(state.shouldAutoOpenStartNodeSelector).toBe(false) + expect(state.nodesDefaultConfigs).toEqual({}) + expect(state.showOnboarding).toBe(false) + expect(state.hasSelectedStartNode).toBe(false) + expect(state.hasShownOnboarding).toBe(false) + }) + + it('should update every workflow slice field through its setters', () => { + const store = createStore(createWorkflowSlice) + + store.setState({ + appId: 'app-1', + appName: 'Workflow App', + }) + store.getState().setNotInitialWorkflow(true) + store.getState().setShouldAutoOpenStartNodeSelector(true) + store.getState().setNodesDefaultConfigs({ start: { title: 'Start' } }) + store.getState().setShowOnboarding(true) + store.getState().setHasSelectedStartNode(true) + store.getState().setHasShownOnboarding(true) + + expect(store.getState()).toMatchObject({ + appId: 'app-1', + appName: 'Workflow App', + notInitialWorkflow: true, + shouldAutoOpenStartNodeSelector: true, + nodesDefaultConfigs: { start: { title: 'Start' } }, + showOnboarding: true, + hasSelectedStartNode: true, + hasShownOnboarding: true, + }) + }) +}) diff --git a/web/app/components/workflow-app/utils.ts b/web/app/components/workflow-app/utils.ts new file mode 100644 index 0000000000..1284c612ef --- /dev/null +++ b/web/app/components/workflow-app/utils.ts @@ -0,0 +1,107 @@ +import type { Features as FeaturesData } from '@/app/components/base/features/types' +import type { FileUploadConfigResponse } from '@/models/common' +import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' + +type TriggerStatusLike = { + node_id: string + status: string +} + +type FileUploadFeatureLike = { + enabled?: boolean + allowed_file_types?: SupportUploadFileTypes[] + allowed_file_extensions?: string[] + allowed_file_upload_methods?: TransferMethod[] + number_limits?: number + image?: { + enabled?: boolean + number_limits?: number + transfer_methods?: TransferMethod[] + } +} + +type WorkflowFeaturesLike = { + file_upload?: FileUploadFeatureLike + opening_statement?: string + suggested_questions?: string[] + suggested_questions_after_answer?: { enabled?: boolean } + speech_to_text?: { enabled?: boolean } + text_to_speech?: { enabled?: boolean } + retriever_resource?: { enabled?: boolean } + sensitive_word_avoidance?: { enabled?: boolean } +} + +export const buildTriggerStatusMap = (triggers: TriggerStatusLike[]) => { + return triggers.reduce>((acc, trigger) => { + acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' + return acc + }, {}) +} + +export const coerceReplayUserInputs = (rawInputs: unknown): Record | null => { + if (!rawInputs || typeof rawInputs !== 'object' || Array.isArray(rawInputs)) + return null + + const userInputs: Record = {} + + Object.entries(rawInputs as Record).forEach(([key, value]) => { + if (key.startsWith('sys.')) + return + + if (value == null) { + userInputs[key] = '' + return + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + userInputs[key] = value + return + } + + try { + userInputs[key] = JSON.stringify(value) + } + catch { + userInputs[key] = String(value) + } + }) + + return userInputs +} + +export const buildInitialFeatures = ( + featuresSource: WorkflowFeaturesLike | null | undefined, + fileUploadConfigResponse: FileUploadConfigResponse | undefined, +): FeaturesData => { + const features = featuresSource || {} + const fileUpload = features.file_upload + const imageUpload = fileUpload?.image + + return { + file: { + image: { + enabled: !!imageUpload?.enabled, + number_limits: imageUpload?.number_limits || 3, + transfer_methods: imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url], + }, + enabled: !!(fileUpload?.enabled || imageUpload?.enabled), + allowed_file_types: fileUpload?.allowed_file_types || [SupportUploadFileTypes.image], + allowed_file_extensions: fileUpload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`), + allowed_file_upload_methods: fileUpload?.allowed_file_upload_methods || imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: fileUpload?.number_limits || imageUpload?.number_limits || 3, + fileUploadConfig: fileUploadConfigResponse, + }, + opening: { + enabled: !!features.opening_statement, + opening_statement: features.opening_statement, + suggested_questions: features.suggested_questions, + }, + suggested: features.suggested_questions_after_answer || { enabled: false }, + speech2text: features.speech_to_text || { enabled: false }, + text2speech: features.text_to_speech || { enabled: false }, + citation: features.retriever_resource || { enabled: false }, + moderation: features.sensitive_word_avoidance || { enabled: false }, + } +} diff --git a/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx new file mode 100644 index 0000000000..61e5410aac --- /dev/null +++ b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx @@ -0,0 +1,260 @@ +import { render, screen } from '@testing-library/react' +import CandidateNodeMain from '../candidate-node-main' +import { CUSTOM_NODE } from '../constants' +import { CUSTOM_NOTE_NODE } from '../note-node/constants' +import { BlockEnum } from '../types' +import { createNode } from './fixtures' + +const mockUseEventListener = vi.hoisted(() => vi.fn()) +const mockUseStoreApi = vi.hoisted(() => vi.fn()) +const mockUseReactFlow = vi.hoisted(() => vi.fn()) +const mockUseViewport = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockUseWorkflowStore = vi.hoisted(() => vi.fn()) +const mockUseHooks = vi.hoisted(() => vi.fn()) +const mockCustomNode = vi.hoisted(() => vi.fn()) +const mockCustomNoteNode = vi.hoisted(() => vi.fn()) +const mockGetIterationStartNode = vi.hoisted(() => vi.fn()) +const mockGetLoopStartNode = vi.hoisted(() => vi.fn()) + +vi.mock('ahooks', () => ({ + useEventListener: (...args: unknown[]) => mockUseEventListener(...args), +})) + +vi.mock('reactflow', () => ({ + useStoreApi: () => mockUseStoreApi(), + useReactFlow: () => mockUseReactFlow(), + useViewport: () => mockUseViewport(), + Position: { + Left: 'left', + Right: 'right', + }, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { mousePosition: { + pageX: number + pageY: number + elementX: number + elementY: number + } }) => unknown) => mockUseStore(selector), + useWorkflowStore: () => mockUseWorkflowStore(), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesInteractions: () => mockUseHooks().useNodesInteractions(), + useNodesSyncDraft: () => mockUseHooks().useNodesSyncDraft(), + useWorkflowHistory: () => mockUseHooks().useWorkflowHistory(), + useAutoGenerateWebhookUrl: () => mockUseHooks().useAutoGenerateWebhookUrl(), + WorkflowHistoryEvent: { + NodeAdd: 'NodeAdd', + NoteAdd: 'NoteAdd', + }, +})) + +vi.mock('@/app/components/workflow/nodes', () => ({ + __esModule: true, + default: (props: { id: string }) => { + mockCustomNode(props) + return
{props.id}
+ }, +})) + +vi.mock('@/app/components/workflow/note-node', () => ({ + __esModule: true, + default: (props: { id: string }) => { + mockCustomNoteNode(props) + return
{props.id}
+ }, +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + getIterationStartNode: (...args: unknown[]) => mockGetIterationStartNode(...args), + getLoopStartNode: (...args: unknown[]) => mockGetLoopStartNode(...args), +})) + +describe('CandidateNodeMain', () => { + const mockSetNodes = vi.fn() + const mockHandleNodeSelect = vi.fn() + const mockSaveStateToHistory = vi.fn() + const mockHandleSyncWorkflowDraft = vi.fn() + const mockAutoGenerateWebhookUrl = vi.fn() + const mockWorkflowStoreSetState = vi.fn() + const createNodesInteractions = () => ({ + handleNodeSelect: mockHandleNodeSelect, + }) + const createWorkflowHistory = () => ({ + saveStateToHistory: mockSaveStateToHistory, + }) + const createNodesSyncDraft = () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }) + const createAutoGenerateWebhookUrl = () => mockAutoGenerateWebhookUrl + const eventHandlers: Partial void }) => void>> = {} + let nodes = [createNode({ id: 'existing-node' })] + + beforeEach(() => { + vi.clearAllMocks() + nodes = [createNode({ id: 'existing-node' })] + eventHandlers.click = undefined + eventHandlers.contextmenu = undefined + + mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu', handler: (event: { preventDefault: () => void }) => void) => { + eventHandlers[event] = handler + }) + mockUseStoreApi.mockReturnValue({ + getState: () => ({ + getNodes: () => nodes, + setNodes: mockSetNodes, + }), + }) + mockUseReactFlow.mockReturnValue({ + screenToFlowPosition: ({ x, y }: { x: number, y: number }) => ({ x: x + 10, y: y + 20 }), + }) + mockUseViewport.mockReturnValue({ zoom: 1.5 }) + mockUseStore.mockImplementation((selector: (state: { mousePosition: { + pageX: number + pageY: number + elementX: number + elementY: number + } }) => unknown) => selector({ + mousePosition: { + pageX: 100, + pageY: 200, + elementX: 30, + elementY: 40, + }, + })) + mockUseWorkflowStore.mockReturnValue({ + setState: mockWorkflowStoreSetState, + }) + mockUseHooks.mockReturnValue({ + useNodesInteractions: createNodesInteractions, + useWorkflowHistory: createWorkflowHistory, + useNodesSyncDraft: createNodesSyncDraft, + useAutoGenerateWebhookUrl: createAutoGenerateWebhookUrl, + }) + mockHandleSyncWorkflowDraft.mockImplementation((_isSync: boolean, _force: boolean, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + mockGetIterationStartNode.mockReturnValue(createNode({ id: 'iteration-start' })) + mockGetLoopStartNode.mockReturnValue(createNode({ id: 'loop-start' })) + }) + + it('should render the candidate node and commit a webhook node on click', () => { + const candidateNode = createNode({ + id: 'candidate-webhook', + type: CUSTOM_NODE, + data: { + type: BlockEnum.TriggerWebhook, + title: 'Webhook Candidate', + _isCandidate: true, + }, + }) + + const { container } = render() + + expect(screen.getByTestId('candidate-custom-node')).toHaveTextContent('candidate-webhook') + expect(container.firstChild).toHaveStyle({ + left: '30px', + top: '40px', + transform: 'scale(1.5)', + }) + + eventHandlers.click?.({ preventDefault: vi.fn() }) + + expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ id: 'existing-node' }), + expect.objectContaining({ + id: 'candidate-webhook', + position: { x: 110, y: 220 }, + data: expect.objectContaining({ _isCandidate: false }), + }), + ])) + expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeAdd', { nodeId: 'candidate-webhook' }) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined }) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('candidate-webhook') + expect(mockHandleNodeSelect).not.toHaveBeenCalled() + }) + + it('should save note candidates as notes and select the inserted note', () => { + const candidateNode = createNode({ + id: 'candidate-note', + type: CUSTOM_NOTE_NODE, + data: { + type: BlockEnum.Code, + title: 'Note Candidate', + _isCandidate: true, + }, + }) + + render() + + expect(screen.getByTestId('candidate-note-node')).toHaveTextContent('candidate-note') + + eventHandlers.click?.({ preventDefault: vi.fn() }) + + expect(mockSaveStateToHistory).toHaveBeenCalledWith('NoteAdd', { nodeId: 'candidate-note' }) + expect(mockHandleNodeSelect).toHaveBeenCalledWith('candidate-note') + }) + + it('should append iteration and loop start helper nodes for control-flow candidates', () => { + const iterationNode = createNode({ + id: 'candidate-iteration', + type: CUSTOM_NODE, + data: { + type: BlockEnum.Iteration, + title: 'Iteration Candidate', + _isCandidate: true, + }, + }) + const loopNode = createNode({ + id: 'candidate-loop', + type: CUSTOM_NODE, + data: { + type: BlockEnum.Loop, + title: 'Loop Candidate', + _isCandidate: true, + }, + }) + + const { rerender } = render() + + eventHandlers.click?.({ preventDefault: vi.fn() }) + expect(mockGetIterationStartNode).toHaveBeenCalledWith('candidate-iteration') + expect(mockSetNodes.mock.calls[0][0]).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'candidate-iteration' }), + expect.objectContaining({ id: 'iteration-start' }), + ])) + + rerender() + eventHandlers.click?.({ preventDefault: vi.fn() }) + + expect(mockGetLoopStartNode).toHaveBeenCalledWith('candidate-loop') + expect(mockSetNodes.mock.calls[1][0]).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'candidate-loop' }), + expect.objectContaining({ id: 'loop-start' }), + ])) + }) + + it('should clear the candidate node on contextmenu', () => { + const candidateNode = createNode({ + id: 'candidate-context', + type: CUSTOM_NODE, + data: { + type: BlockEnum.Code, + title: 'Context Candidate', + _isCandidate: true, + }, + }) + + render() + + eventHandlers.contextmenu?.({ preventDefault: vi.fn() }) + + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined }) + }) +}) diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx new file mode 100644 index 0000000000..f8ff9a1a0e --- /dev/null +++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx @@ -0,0 +1,235 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { Position } from 'reactflow' +import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' +import CustomEdge from '../custom-edge' +import { BlockEnum, NodeRunningStatus } from '../types' + +const mockUseAvailableBlocks = vi.hoisted(() => vi.fn()) +const mockUseNodesInteractions = vi.hoisted(() => vi.fn()) +const mockBlockSelector = vi.hoisted(() => vi.fn()) +const mockGradientRender = vi.hoisted(() => vi.fn()) + +vi.mock('reactflow', () => ({ + BaseEdge: (props: { + id: string + path: string + style: { + stroke: string + strokeWidth: number + opacity: number + strokeDasharray?: string + } + }) => ( +
+ ), + EdgeLabelRenderer: ({ children }: { children?: ReactNode }) =>
{children}
, + getBezierPath: () => ['M 0 0', 24, 48], + Position: { + Right: 'right', + Left: 'left', + }, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useAvailableBlocks: (...args: unknown[]) => mockUseAvailableBlocks(...args), + useNodesInteractions: () => mockUseNodesInteractions(), +})) + +vi.mock('@/app/components/workflow/block-selector', () => ({ + __esModule: true, + default: (props: { + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (nodeType: string, pluginDefaultValue?: Record) => void + availableBlocksTypes: string[] + triggerClassName?: () => string + }) => { + mockBlockSelector(props) + return ( + + ) + }, +})) + +vi.mock('@/app/components/workflow/custom-edge-linear-gradient-render', () => ({ + __esModule: true, + default: (props: { + id: string + startColor: string + stopColor: string + }) => { + mockGradientRender(props) + return
{props.id}
+ }, +})) + +describe('CustomEdge', () => { + const mockHandleNodeAdd = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockUseNodesInteractions.mockReturnValue({ + handleNodeAdd: mockHandleNodeAdd, + }) + mockUseAvailableBlocks.mockImplementation((nodeType: BlockEnum) => { + if (nodeType === BlockEnum.Code) + return { availablePrevBlocks: ['code', 'llm'] } + + return { availableNextBlocks: ['llm', 'tool'] } + }) + }) + + it('should render a gradient edge and insert a node between the source and target', () => { + render( + , + ) + + expect(screen.getByTestId('edge-gradient')).toHaveTextContent('edge-1') + expect(mockGradientRender).toHaveBeenCalledWith(expect.objectContaining({ + id: 'edge-1', + startColor: 'var(--color-workflow-link-line-success-handle)', + stopColor: 'var(--color-workflow-link-line-error-handle)', + })) + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'url(#edge-1)') + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-opacity', '0.3') + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-dasharray', '8 8') + expect(screen.getByTestId('block-selector')).toHaveTextContent('llm') + expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({ + transform: 'translate(-50%, -50%) translate(24px, 48px)', + opacity: '0.7', + }) + + fireEvent.click(screen.getByTestId('block-selector')) + + expect(mockHandleNodeAdd).toHaveBeenCalledWith( + { + nodeType: 'llm', + pluginDefaultValue: { provider: 'openai' }, + }, + { + prevNodeId: 'source-node', + prevNodeSourceHandle: 'source', + nextNodeId: 'target-node', + nextNodeTargetHandle: 'target', + }, + ) + }) + + it('should prefer the running stroke color when the edge is selected', () => { + render( + , + ) + + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-handle)') + }) + + it('should use the fail-branch running color while the connected node is hovering', () => { + render( + , + ) + + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-failure-handle)') + }) + + it('should fall back to the default edge color when no highlight state is active', () => { + render( + , + ) + + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)') + expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all') + }) +}) diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx new file mode 100644 index 0000000000..7418b7f313 --- /dev/null +++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx @@ -0,0 +1,114 @@ +import type { Node } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import NodeContextmenu from '../node-contextmenu' + +const mockUseClickAway = vi.hoisted(() => vi.fn()) +const mockUseNodes = vi.hoisted(() => vi.fn()) +const mockUsePanelInteractions = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockPanelOperatorPopup = vi.hoisted(() => vi.fn()) + +vi.mock('ahooks', () => ({ + useClickAway: (...args: unknown[]) => mockUseClickAway(...args), +})) + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + __esModule: true, + default: () => mockUseNodes(), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + usePanelInteractions: () => mockUsePanelInteractions(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({ + __esModule: true, + default: (props: { + id: string + data: Node['data'] + showHelpLink: boolean + onClosePopup: () => void + }) => { + mockPanelOperatorPopup(props) + return ( + + ) + }, +})) + +describe('NodeContextmenu', () => { + const mockHandleNodeContextmenuCancel = vi.fn() + let nodeMenu: { nodeId: string, left: number, top: number } | undefined + let nodes: Node[] + let clickAwayHandler: (() => void) | undefined + + beforeEach(() => { + vi.clearAllMocks() + nodeMenu = undefined + nodes = [{ + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + title: 'Node 1', + desc: '', + type: 'code' as never, + }, + } as Node] + clickAwayHandler = undefined + + mockUseClickAway.mockImplementation((handler: () => void) => { + clickAwayHandler = handler + }) + mockUseNodes.mockImplementation(() => nodes) + mockUsePanelInteractions.mockReturnValue({ + handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel, + }) + mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu })) + }) + + it('should stay hidden when the node menu is absent', () => { + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(mockPanelOperatorPopup).not.toHaveBeenCalled() + }) + + it('should stay hidden when the referenced node cannot be found', () => { + nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 } + + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(mockPanelOperatorPopup).not.toHaveBeenCalled() + }) + + it('should render the popup at the stored position and close on popup/click-away actions', () => { + nodeMenu = { nodeId: 'node-1', left: 80, top: 120 } + const { container } = render() + + expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1') + expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({ + id: 'node-1', + data: expect.objectContaining({ title: 'Node 1' }), + showHelpLink: true, + })) + expect(container.firstChild).toHaveStyle({ + left: '80px', + top: '120px', + }) + + fireEvent.click(screen.getByRole('button')) + clickAwayHandler?.() + + expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx new file mode 100644 index 0000000000..914c1be617 --- /dev/null +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -0,0 +1,151 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import PanelContextmenu from '../panel-contextmenu' + +const mockUseClickAway = vi.hoisted(() => vi.fn()) +const mockUseTranslation = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockUseNodesInteractions = vi.hoisted(() => vi.fn()) +const mockUsePanelInteractions = vi.hoisted(() => vi.fn()) +const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn()) +const mockUseOperator = vi.hoisted(() => vi.fn()) +const mockUseDSL = vi.hoisted(() => vi.fn()) + +vi.mock('ahooks', () => ({ + useClickAway: (...args: unknown[]) => mockUseClickAway(...args), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + panelMenu?: { left: number, top: number } + clipboardElements: unknown[] + setShowImportDSLModal: (visible: boolean) => void + }) => unknown) => mockUseStore(selector), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesInteractions: () => mockUseNodesInteractions(), + usePanelInteractions: () => mockUsePanelInteractions(), + useWorkflowStartRun: () => mockUseWorkflowStartRun(), + useDSL: () => mockUseDSL(), +})) + +vi.mock('@/app/components/workflow/operator/hooks', () => ({ + useOperator: () => mockUseOperator(), +})) + +vi.mock('@/app/components/workflow/operator/add-block', () => ({ + __esModule: true, + default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => ( +
{renderTrigger()}
+ ), +})) + +vi.mock('@/app/components/base/divider', () => ({ + __esModule: true, + default: ({ className }: { className?: string }) =>
, +})) + +vi.mock('@/app/components/workflow/shortcuts-name', () => ({ + __esModule: true, + default: ({ keys }: { keys: string[] }) => {keys.join('+')}, +})) + +describe('PanelContextmenu', () => { + const mockHandleNodesPaste = vi.fn() + const mockHandlePaneContextmenuCancel = vi.fn() + const mockHandleStartWorkflowRun = vi.fn() + const mockHandleAddNote = vi.fn() + const mockExportCheck = vi.fn() + const mockSetShowImportDSLModal = vi.fn() + let panelMenu: { left: number, top: number } | undefined + let clipboardElements: unknown[] + let clickAwayHandler: (() => void) | undefined + + beforeEach(() => { + vi.clearAllMocks() + panelMenu = undefined + clipboardElements = [] + clickAwayHandler = undefined + + mockUseClickAway.mockImplementation((handler: () => void) => { + clickAwayHandler = handler + }) + mockUseTranslation.mockReturnValue({ + t: (key: string) => key, + }) + mockUseStore.mockImplementation((selector: (state: { + panelMenu?: { left: number, top: number } + clipboardElements: unknown[] + setShowImportDSLModal: (visible: boolean) => void + }) => unknown) => selector({ + panelMenu, + clipboardElements, + setShowImportDSLModal: mockSetShowImportDSLModal, + })) + mockUseNodesInteractions.mockReturnValue({ + handleNodesPaste: mockHandleNodesPaste, + }) + mockUsePanelInteractions.mockReturnValue({ + handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, + }) + mockUseWorkflowStartRun.mockReturnValue({ + handleStartWorkflowRun: mockHandleStartWorkflowRun, + }) + mockUseOperator.mockReturnValue({ + handleAddNote: mockHandleAddNote, + }) + mockUseDSL.mockReturnValue({ + exportCheck: mockExportCheck, + }) + }) + + it('should stay hidden when the panel menu is absent', () => { + render() + + expect(screen.queryByTestId('add-block')).not.toBeInTheDocument() + }) + + it('should keep paste disabled when the clipboard is empty', () => { + panelMenu = { left: 24, top: 48 } + + render() + + fireEvent.click(screen.getByText('common.pasteHere')) + + expect(mockHandleNodesPaste).not.toHaveBeenCalled() + expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled() + }) + + it('should render actions, position the menu, and execute each action', () => { + panelMenu = { left: 24, top: 48 } + clipboardElements = [{ id: 'copied-node' }] + const { container } = render() + + expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock') + expect(screen.getByTestId('shortcut-alt-r')).toHaveTextContent('alt+r') + expect(screen.getByTestId('shortcut-ctrl-v')).toHaveTextContent('ctrl+v') + expect(container.firstChild).toHaveStyle({ + left: '24px', + top: '48px', + }) + + fireEvent.click(screen.getByText('nodes.note.addNote')) + fireEvent.click(screen.getByText('common.run')) + fireEvent.click(screen.getByText('common.pasteHere')) + fireEvent.click(screen.getByText('export')) + fireEvent.click(screen.getByText('common.importDSL')) + clickAwayHandler?.() + + expect(mockHandleAddNote).toHaveBeenCalledTimes(1) + expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1) + expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1) + expect(mockExportCheck).toHaveBeenCalledTimes(1) + expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true) + expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4) + }) +}) diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx new file mode 100644 index 0000000000..247184349d --- /dev/null +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -0,0 +1,275 @@ +import type { Edge, Node } from '../types' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { useEffect } from 'react' +import { useNodes } from 'reactflow' +import SelectionContextmenu from '../selection-contextmenu' +import { useWorkflowHistoryStore } from '../workflow-history-store' +import { createEdge, createNode } from './fixtures' +import { renderWorkflowFlowComponent } from './workflow-test-env' + +let latestNodes: Node[] = [] +let latestHistoryEvent: string | undefined +const mockGetNodesReadOnly = vi.fn() + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks') + return { + ...actual, + useNodesReadOnly: () => ({ + getNodesReadOnly: mockGetNodesReadOnly, + }), + } +}) + +const RuntimeProbe = () => { + latestNodes = useNodes() as Node[] + const { store } = useWorkflowHistoryStore() + + useEffect(() => { + latestHistoryEvent = store.getState().workflowHistoryEvent + return store.subscribe((state) => { + latestHistoryEvent = state.workflowHistoryEvent + }) + }, [store]) + + return null +} + +const hooksStoreProps = { + doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), +} + +const renderSelectionMenu = (options?: { + nodes?: Node[] + edges?: Edge[] + initialStoreState?: Record +}) => { + latestNodes = [] + latestHistoryEvent = undefined + + const nodes = options?.nodes ?? [] + const edges = options?.edges ?? [] + + return renderWorkflowFlowComponent( +
+ + +
, + { + nodes, + edges, + hooksStoreProps, + historyStore: { nodes, edges }, + initialStoreState: options?.initialStoreState, + reactFlowProps: { fitView: false }, + }, + ) +} + +describe('SelectionContextmenu', () => { + beforeEach(() => { + vi.clearAllMocks() + latestNodes = [] + latestHistoryEvent = undefined + mockGetNodesReadOnly.mockReset() + mockGetNodesReadOnly.mockReturnValue(false) + }) + + it('should not render when selectionMenu is absent', () => { + renderSelectionMenu() + + expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument() + }) + + it('should keep the menu inside the workflow container bounds', () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), + ] + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 780, top: 590 } }) + }) + + const menu = screen.getByTestId('selection-contextmenu') + expect(menu).toHaveStyle({ left: '540px', top: '210px' }) + }) + + it('should close itself when only one node is selected', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 120, top: 120 } }) + }) + + await waitFor(() => { + expect(store.getState().selectionMenu).toBeUndefined() + }) + }) + + it('should align selected nodes to the left and save history', async () => { + vi.useFakeTimers() + const nodes = [ + createNode({ id: 'n1', selected: true, position: { x: 20, y: 40 }, width: 40, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 90 }, width: 60, height: 30 }), + ] + + const { store } = renderSelectionMenu({ + nodes, + edges: [createEdge({ source: 'n1', target: 'n2' })], + initialStoreState: { + helpLineHorizontal: { y: 10 } as never, + helpLineVertical: { x: 10 } as never, + }, + }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(20) + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(20) + expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().helpLineHorizontal).toBeUndefined() + expect(store.getState().helpLineVertical).toBeUndefined() + + act(() => { + store.getState().flushPendingSync() + vi.advanceTimersByTime(600) + }) + + expect(hooksStoreProps.doSyncWorkflowDraft).toHaveBeenCalled() + expect(latestHistoryEvent).toBe('NodeDragStop') + vi.useRealTimers() + }) + + it('should distribute selected nodes horizontally', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 100, y: 20 }, width: 20, height: 20 }), + createNode({ id: 'n3', selected: true, position: { x: 300, y: 30 }, width: 20, height: 20 }), + ] + + const { store } = renderSelectionMenu({ + nodes, + }) + + act(() => { + store.setState({ selectionMenu: { left: 160, top: 120 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal')) + + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(150) + }) + + it('should ignore child nodes when the selected container is aligned', async () => { + const nodes = [ + createNode({ + id: 'container', + selected: true, + position: { x: 200, y: 0 }, + width: 100, + height: 80, + data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, + }), + createNode({ + id: 'child', + selected: true, + position: { x: 210, y: 10 }, + width: 30, + height: 20, + }), + createNode({ + id: 'other', + selected: true, + position: { x: 40, y: 60 }, + width: 40, + height: 20, + }), + ] + + const { store } = renderSelectionMenu({ + nodes, + }) + + act(() => { + store.setState({ selectionMenu: { left: 180, top: 120 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(40) + expect(latestNodes.find(node => node.id === 'other')?.position.x).toBe(40) + expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(210) + }) + + it('should cancel when align bounds cannot be resolved', () => { + const nodes = [ + createNode({ id: 'n1', selected: true }), + createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 } }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(store.getState().selectionMenu).toBeUndefined() + }) + + it('should cancel without aligning when nodes are read only', () => { + mockGetNodesReadOnly.mockReturnValue(true) + const nodes = [ + createNode({ id: 'n1', selected: true, width: 40, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(store.getState().selectionMenu).toBeUndefined() + expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0) + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80) + }) + + it('should cancel when alignable nodes shrink to one item', () => { + const nodes = [ + createNode({ + id: 'container', + selected: true, + width: 40, + height: 20, + data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, + }), + createNode({ id: 'child', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(store.getState().selectionMenu).toBeUndefined() + expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(0) + expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(80) + }) +}) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts new file mode 100644 index 0000000000..ac1cf67970 --- /dev/null +++ b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts @@ -0,0 +1,79 @@ +import { DSLImportStatus } from '@/models/app' +import { AppModeEnum } from '@/types/app' +import { BlockEnum } from '../types' +import { + getInvalidNodeTypes, + isImportCompleted, + normalizeWorkflowFeatures, + validateDSLContent, +} from '../update-dsl-modal.helpers' + +describe('update-dsl-modal helpers', () => { + describe('dsl validation', () => { + it('should reject advanced chat dsl content with disallowed trigger nodes', () => { + const content = ` +workflow: + graph: + nodes: + - data: + type: trigger-webhook +` + + expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false) + }) + + it('should reject malformed yaml and answer nodes in non-advanced mode', () => { + expect(validateDSLContent('[', AppModeEnum.CHAT)).toBe(false) + expect(validateDSLContent(` +workflow: + graph: + nodes: + - data: + type: answer +`, AppModeEnum.CHAT)).toBe(false) + }) + + it('should accept valid node types for advanced chat mode', () => { + expect(validateDSLContent(` +workflow: + graph: + nodes: + - data: + type: tool +`, AppModeEnum.ADVANCED_CHAT)).toBe(true) + }) + + it('should expose the invalid node sets per mode', () => { + expect(getInvalidNodeTypes(AppModeEnum.ADVANCED_CHAT)).toEqual( + expect.arrayContaining([BlockEnum.End, BlockEnum.TriggerWebhook]), + ) + expect(getInvalidNodeTypes(AppModeEnum.CHAT)).toEqual([BlockEnum.Answer]) + }) + }) + + describe('status and feature normalization', () => { + it('should treat completed statuses as successful imports', () => { + expect(isImportCompleted(DSLImportStatus.COMPLETED)).toBe(true) + expect(isImportCompleted(DSLImportStatus.COMPLETED_WITH_WARNINGS)).toBe(true) + expect(isImportCompleted(DSLImportStatus.PENDING)).toBe(false) + }) + + it('should normalize workflow features with defaults', () => { + const features = normalizeWorkflowFeatures({ + file_upload: { + image: { + enabled: true, + }, + }, + opening_statement: 'hello', + suggested_questions: ['what can you do?'], + }) + + expect(features.file.enabled).toBe(true) + expect(features.file.number_limits).toBe(3) + expect(features.opening.enabled).toBe(true) + expect(features.suggested).toEqual({ enabled: false }) + expect(features.text2speech).toEqual({ enabled: false }) + }) + }) +}) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx new file mode 100644 index 0000000000..82645f2028 --- /dev/null +++ b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx @@ -0,0 +1,365 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { EventEmitterValue } from '@/context/event-emitter' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { toast } from '@/app/components/base/ui/toast' +import { EventEmitterContext } from '@/context/event-emitter' +import { DSLImportStatus } from '@/models/app' +import UpdateDSLModal from '../update-dsl-modal' + +class MockFileReader { + onload: ((this: FileReader, event: ProgressEvent) => void) | null = null + + readAsText(_file: Blob) { + const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: tool\n' } } as unknown as ProgressEvent + this.onload?.call(this as unknown as FileReader, event) + } +} + +vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) +const mockEmit = vi.fn() + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})) + +const mockImportDSL = vi.fn() +const mockImportDSLConfirm = vi.fn() +vi.mock('@/service/apps', () => ({ + importDSL: (payload: unknown) => mockImportDSL(payload), + importDSLConfirm: (payload: unknown) => mockImportDSLConfirm(payload), +})) + +const mockFetchWorkflowDraft = vi.fn() +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: (path: string) => mockFetchWorkflowDraft(path), +})) + +const mockHandleCheckPluginDependencies = vi.fn() +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: mockHandleCheckPluginDependencies, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { id: string, mode: string } }) => unknown) => selector({ + appDetail: { + id: 'app-1', + mode: 'chat', + }, + }), +})) + +vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ + default: ({ updateFile }: { updateFile: (file?: File) => void }) => ( + updateFile(event.target.files?.[0])} + /> + ), +})) + +describe('UpdateDSLModal', () => { + const mockToastError = vi.mocked(toast.error) + const defaultProps = { + onCancel: vi.fn(), + onBackup: vi.fn(), + onImport: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + mockFetchWorkflowDraft.mockResolvedValue({ + graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, + features: {}, + hash: 'hash-1', + conversation_variables: [], + environment_variables: [], + }) + mockImportDSL.mockResolvedValue({ + id: 'import-1', + status: DSLImportStatus.COMPLETED, + app_id: 'app-1', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + app_id: 'app-1', + }) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + }) + + const renderModal = (props = defaultProps) => { + const eventEmitter = { emit: mockEmit } as unknown as EventEmitter + + return render( + + + , + ) + } + + it('should keep import disabled until a file is selected', () => { + renderModal() + + expect(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })).toBeDisabled() + }) + + it('should call backup handler from the warning area', () => { + renderModal() + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.backupCurrentDraft' })) + + expect(defaultProps.onBackup).toHaveBeenCalledTimes(1) + }) + + it('should import a valid file and emit workflow update payload', async () => { + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalledWith(expect.objectContaining({ + app_id: 'app-1', + yaml_content: expect.stringContaining('workflow:'), + })) + }) + + expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'WORKFLOW_DATA_UPDATE', + })) + expect(defaultProps.onImport).toHaveBeenCalledTimes(1) + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1) + }) + + it('should show an error notification when import fails', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-1', + status: DSLImportStatus.FAILED, + app_id: 'app-1', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['invalid'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should open the version warning modal for pending imports and confirm them', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-2', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-2' }) + }) + }) + + it('should open the pending modal after the timeout and allow dismissing it', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-5', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Cancel' })).toBeInTheDocument() + }, { timeout: 1000 }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Cancel' })) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'app.newApp.Confirm' })).not.toBeInTheDocument() + }) + }) + + it('should show an error when the selected file content is invalid for the current app mode', async () => { + class InvalidDSLFileReader extends MockFileReader { + readAsText(_file: Blob) { + const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: answer\n' } } as unknown as ProgressEvent + this.onload?.call(this as unknown as FileReader, event) + } + } + + vi.stubGlobal('FileReader', InvalidDSLFileReader as unknown as typeof FileReader) + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + expect(mockImportDSL).not.toHaveBeenCalled() + + vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) + }) + + it('should show an error notification when import throws', async () => { + mockImportDSL.mockRejectedValue(new Error('boom')) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when completed import does not return an app id', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-3', + status: DSLImportStatus.COMPLETED, + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when confirming a pending import fails', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-4', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.FAILED, + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when confirming a pending import throws', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-6', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockRejectedValue(new Error('boom')) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when a confirmed pending import completes without an app id', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-7', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/help-line/__tests__/index.spec.tsx b/web/app/components/workflow/help-line/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f58c9c5d02 --- /dev/null +++ b/web/app/components/workflow/help-line/__tests__/index.spec.tsx @@ -0,0 +1,61 @@ +import { render } from '@testing-library/react' +import HelpLine from '../index' + +const mockUseViewport = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) + +vi.mock('reactflow', () => ({ + useViewport: () => mockUseViewport(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + helpLineHorizontal?: { top: number, left: number, width: number } + helpLineVertical?: { top: number, left: number, height: number } + }) => unknown) => mockUseStore(selector), +})) + +describe('HelpLine', () => { + let helpLineHorizontal: { top: number, left: number, width: number } | undefined + let helpLineVertical: { top: number, left: number, height: number } | undefined + + beforeEach(() => { + vi.clearAllMocks() + helpLineHorizontal = undefined + helpLineVertical = undefined + + mockUseViewport.mockReturnValue({ x: 10, y: 20, zoom: 2 }) + mockUseStore.mockImplementation((selector: (state: { + helpLineHorizontal?: { top: number, left: number, width: number } + helpLineVertical?: { top: number, left: number, height: number } + }) => unknown) => selector({ + helpLineHorizontal, + helpLineVertical, + })) + }) + + it('should render nothing when both help lines are absent', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should render the horizontal and vertical guide lines using viewport offsets and zoom', () => { + helpLineHorizontal = { top: 30, left: 40, width: 50 } + helpLineVertical = { top: 60, left: 70, height: 80 } + + const { container } = render() + const [horizontal, vertical] = Array.from(container.querySelectorAll('div')) + + expect(horizontal).toHaveStyle({ + top: '80px', + left: '90px', + width: '100px', + }) + expect(vertical).toHaveStyle({ + top: '140px', + left: '150px', + height: '160px', + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts new file mode 100644 index 0000000000..5811f14a60 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts @@ -0,0 +1,171 @@ +import type { ModelConfig, VisionSetting } from '@/app/components/workflow/types' +import { act, renderHook } from '@testing-library/react' +import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { Resolution } from '@/types/app' +import useConfigVision from '../use-config-vision' + +const mockUseTextGenerationCurrentProviderAndModelAndModelList = vi.hoisted(() => vi.fn()) +const mockUseIsChatMode = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) => + mockUseTextGenerationCurrentProviderAndModelAndModelList(...args), +})) + +vi.mock('../use-workflow', () => ({ + useIsChatMode: () => mockUseIsChatMode(), +})) + +const createModel = (overrides: Partial = {}): ModelConfig => ({ + provider: 'openai', + name: 'gpt-4o', + mode: 'chat', + completion_params: [], + ...overrides, +}) + +const createVisionPayload = (overrides: Partial<{ enabled: boolean, configs?: VisionSetting }> = {}) => ({ + enabled: false, + ...overrides, +}) + +describe('useConfigVision', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseIsChatMode.mockReturnValue(false) + mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({ + currentModel: { + features: [], + }, + }) + }) + + it('should expose vision capability and enable default chat configs for vision models', () => { + const onChange = vi.fn() + mockUseIsChatMode.mockReturnValue(true) + mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({ + currentModel: { + features: [ModelFeatureEnum.vision], + }, + }) + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload(), + onChange, + })) + + expect(result.current.isVisionModel).toBe(true) + + act(() => { + result.current.handleVisionResolutionEnabledChange(true) + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + }, + }) + }) + + it('should clear configs when disabling vision resolution', () => { + const onChange = vi.fn() + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.low, + variable_selector: ['node', 'files'], + }, + }), + onChange, + })) + + act(() => { + result.current.handleVisionResolutionEnabledChange(false) + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: false, + }) + }) + + it('should update the resolution config payload directly', () => { + const onChange = vi.fn() + const config: VisionSetting = { + detail: Resolution.low, + variable_selector: ['upstream', 'images'], + } + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ enabled: true }), + onChange, + })) + + act(() => { + result.current.handleVisionResolutionChange(config) + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: true, + configs: config, + }) + }) + + it('should disable vision settings when the selected model is no longer a vision model', () => { + const onChange = vi.fn() + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + }, + }), + onChange, + })) + + act(() => { + result.current.handleModelChanged() + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: false, + }) + }) + + it('should reset enabled vision configs when the model changes but still supports vision', () => { + const onChange = vi.fn() + mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({ + currentModel: { + features: [ModelFeatureEnum.vision], + }, + }) + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.low, + variable_selector: ['old', 'files'], + }, + }), + onChange, + })) + + act(() => { + result.current.handleModelChanged() + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: [], + }, + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx new file mode 100644 index 0000000000..d66e3ebe4a --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx @@ -0,0 +1,146 @@ +import { renderHook } from '@testing-library/react' +import { BlockEnum } from '../../types' +import { useDynamicTestRunOptions } from '../use-dynamic-test-run-options' + +const mockUseTranslation = vi.hoisted(() => vi.fn()) +const mockUseNodes = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockUseAllTriggerPlugins = vi.hoisted(() => vi.fn()) +const mockGetWorkflowEntryNode = vi.hoisted(() => vi.fn()) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation(), +})) + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + __esModule: true, + default: () => mockUseNodes(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + buildInTools: unknown[] + customTools: unknown[] + workflowTools: unknown[] + mcpTools: unknown[] + }) => unknown) => mockUseStore(selector), +})) + +vi.mock('@/service/use-triggers', () => ({ + useAllTriggerPlugins: () => mockUseAllTriggerPlugins(), +})) + +vi.mock('@/app/components/workflow/utils/workflow-entry', () => ({ + getWorkflowEntryNode: (...args: unknown[]) => mockGetWorkflowEntryNode(...args), +})) + +describe('useDynamicTestRunOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseTranslation.mockReturnValue({ + t: (key: string) => key, + }) + mockUseStore.mockImplementation((selector: (state: { + buildInTools: unknown[] + customTools: unknown[] + workflowTools: unknown[] + mcpTools: unknown[] + }) => unknown) => selector({ + buildInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + })) + mockUseAllTriggerPlugins.mockReturnValue({ + data: [{ + name: 'plugin-provider', + icon: '/plugin-icon.png', + }], + }) + }) + + it('should build user input, trigger options, and a run-all option from workflow nodes', () => { + mockUseNodes.mockReturnValue([ + { + id: 'start-1', + data: { type: BlockEnum.Start, title: 'User Input' }, + }, + { + id: 'schedule-1', + data: { type: BlockEnum.TriggerSchedule, title: 'Daily Schedule' }, + }, + { + id: 'webhook-1', + data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' }, + }, + { + id: 'plugin-1', + data: { + type: BlockEnum.TriggerPlugin, + title: '', + plugin_name: 'Plugin Trigger', + provider_id: 'plugin-provider', + }, + }, + ]) + + const { result } = renderHook(() => useDynamicTestRunOptions()) + + expect(result.current.userInput).toEqual(expect.objectContaining({ + id: 'start-1', + type: 'user_input', + name: 'User Input', + nodeId: 'start-1', + enabled: true, + })) + expect(result.current.triggers).toEqual([ + expect.objectContaining({ + id: 'schedule-1', + type: 'schedule', + name: 'Daily Schedule', + nodeId: 'schedule-1', + }), + expect.objectContaining({ + id: 'webhook-1', + type: 'webhook', + name: 'Webhook Trigger', + nodeId: 'webhook-1', + }), + expect.objectContaining({ + id: 'plugin-1', + type: 'plugin', + name: 'Plugin Trigger', + nodeId: 'plugin-1', + }), + ]) + expect(result.current.runAll).toEqual(expect.objectContaining({ + id: 'run-all', + type: 'all', + relatedNodeIds: ['schedule-1', 'webhook-1', 'plugin-1'], + })) + }) + + it('should fall back to the workflow entry node and omit run-all when only one trigger exists', () => { + mockUseNodes.mockReturnValue([ + { + id: 'webhook-1', + data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' }, + }, + ]) + mockGetWorkflowEntryNode.mockReturnValue({ + id: 'fallback-start', + data: { type: BlockEnum.Start, title: '' }, + }) + + const { result } = renderHook(() => useDynamicTestRunOptions()) + + expect(result.current.userInput).toEqual(expect.objectContaining({ + id: 'fallback-start', + type: 'user_input', + name: 'blocks.start', + nodeId: 'fallback-start', + })) + expect(result.current.triggers).toHaveLength(1) + expect(result.current.runAll).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 8de86edecb..66237b7203 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1820,21 +1820,26 @@ export const useNodesInteractions = () => { newChildren.push(newLoopStartNode!) } else { - // single node paste + // Paste a single regular node. Loop/Iteration nodes are handled above. const selectedNode = nodes.find(node => node.selected) let pastedToNestedBlock = false if (selectedNode) { + // Keep this list aligned with availableBlocksFilter(inContainer) + // in use-available-blocks.ts. const commonNestedDisallowPasteNodes = [ - // end node only can be placed outermost layer BlockEnum.End, + BlockEnum.Iteration, + BlockEnum.Loop, + BlockEnum.DataSource, + BlockEnum.KnowledgeBase, + BlockEnum.HumanInput, ] - // handle disallow paste node if (commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type)) return - // handle paste to nested block + // If a Loop/Iteration container is selected, paste into it as a child. if (selectedNode.data.type === BlockEnum.Iteration || selectedNode.data.type === BlockEnum.Loop) { const isIteration = selectedNode.data.type === BlockEnum.Iteration @@ -1849,10 +1854,10 @@ export const useNodesInteractions = () => { x: newNode.position.x, y: newNode.position.y, } - // set position base on parent node + // Rebase position into the selected container coordinate system. newNode.position = getNestedNodePosition(newNode, selectedNode) - // update parent children array like native add + // Mirror native add behavior by appending parent._children. parentChildrenToAppend.push({ parentId: selectedNode.id, childId: newNode.id, childType: newNode.data.type }) pastedToNestedBlock = true diff --git a/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx new file mode 100644 index 0000000000..6dda819a04 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx @@ -0,0 +1,135 @@ +import type { TFunction } from 'i18next' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import { NodeBody, NodeDescription, NodeHeaderMeta } from '../node-sections' + +describe('node sections', () => { + it('should render loop and loading metadata in the header section', () => { + const t = ((key: string) => key) as unknown as TFunction + + render( + loop-index
} + t={t} + />, + ) + + expect(screen.getByText('loop-index')).toBeInTheDocument() + expect(document.querySelector('.i-ri-loader-2-line')).toBeInTheDocument() + }) + + it('should render the container node body and description branches', () => { + const { rerender } = render( + body-content
} + />, + ) + + expect(screen.getByText('body-content').parentElement).toHaveClass('grow') + + rerender() + expect(screen.getByText('node description')).toBeInTheDocument() + }) + + it('should render iteration parallel metadata and running progress', async () => { + const t = ((key: string) => key) as unknown as TFunction + const user = userEvent.setup() + + render( + , + ) + + expect(screen.getByText('nodes.iteration.parallelModeUpper')).toBeInTheDocument() + await user.hover(screen.getByText('nodes.iteration.parallelModeUpper')) + expect(await screen.findByText('nodes.iteration.parallelModeEnableTitle')).toBeInTheDocument() + expect(screen.getByText('nodes.iteration.parallelModeEnableDesc')).toBeInTheDocument() + expect(screen.getByText('3/3')).toBeInTheDocument() + }) + + it('should render failed, exception, success and paused status icons', () => { + const t = ((key: string) => key) as unknown as TFunction + const { rerender } = render( + , + ) + + expect(document.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-alert-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-pause-circle-fill')).toBeInTheDocument() + }) + + it('should render success icon when inspect vars exist without running status and hide description for loop nodes', () => { + const t = ((key: string) => key) as unknown as TFunction + const { rerender } = render( + , + ) + + expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument() + + rerender() + expect(screen.queryByText('hidden')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts new file mode 100644 index 0000000000..78e1f938c5 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts @@ -0,0 +1,34 @@ +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import { + getLoopIndexTextKey, + getNodeStatusBorders, + isContainerNode, + isEntryWorkflowNode, +} from '../node.helpers' + +describe('node helpers', () => { + it('should derive node border states from running status and selection state', () => { + expect(getNodeStatusBorders(NodeRunningStatus.Running, false, false).showRunningBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, false).showSuccessBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Failed, false, false).showFailedBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Exception, false, false).showExceptionBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, true).showSuccessBorder).toBe(false) + }) + + it('should expose the correct loop translation key per running status', () => { + expect(getLoopIndexTextKey(NodeRunningStatus.Running)).toBe('nodes.loop.currentLoopCount') + expect(getLoopIndexTextKey(NodeRunningStatus.Succeeded)).toBe('nodes.loop.totalLoopCount') + expect(getLoopIndexTextKey(NodeRunningStatus.Failed)).toBe('nodes.loop.totalLoopCount') + expect(getLoopIndexTextKey(NodeRunningStatus.Paused)).toBeUndefined() + }) + + it('should identify entry and container nodes', () => { + expect(isEntryWorkflowNode(BlockEnum.Start)).toBe(true) + expect(isEntryWorkflowNode(BlockEnum.TriggerWebhook)).toBe(true) + expect(isEntryWorkflowNode(BlockEnum.Tool)).toBe(false) + + expect(isContainerNode(BlockEnum.Iteration)).toBe(true) + expect(isContainerNode(BlockEnum.Loop)).toBe(true) + expect(isContainerNode(BlockEnum.Tool)).toBe(false) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx new file mode 100644 index 0000000000..a7f88e983e --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx @@ -0,0 +1,218 @@ +import type { PropsWithChildren } from 'react' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { fireEvent, screen } from '@testing-library/react' +import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import BaseNode from '../node' + +const mockHasNodeInspectVars = vi.fn() +const mockUseNodePluginInstallation = vi.fn() +const mockHandleNodeIterationChildSizeChange = vi.fn() +const mockHandleNodeLoopChildSizeChange = vi.fn() +const mockUseNodeResizeObserver = vi.fn() + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useToolIcon: () => undefined, +})) + +vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ + default: () => ({ + hasNodeInspectVars: mockHasNodeInspectVars, + }), +})) + +vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({ + useNodePluginInstallation: (...args: unknown[]) => mockUseNodePluginInstallation(...args), +})) + +vi.mock('@/app/components/workflow/nodes/iteration/use-interactions', () => ({ + useNodeIterationInteractions: () => ({ + handleNodeIterationChildSizeChange: mockHandleNodeIterationChildSizeChange, + }), +})) + +vi.mock('@/app/components/workflow/nodes/loop/use-interactions', () => ({ + useNodeLoopInteractions: () => ({ + handleNodeLoopChildSizeChange: mockHandleNodeLoopChildSizeChange, + }), +})) + +vi.mock('../use-node-resize-observer', () => ({ + default: (options: { enabled: boolean, onResize: () => void }) => { + mockUseNodeResizeObserver(options) + if (options.enabled) + options.onResize() + }, +})) + +vi.mock('../components/add-variable-popup-with-position', () => ({ + default: () =>
, +})) +vi.mock('../components/entry-node-container', () => ({ + __esModule: true, + StartNodeTypeEnum: { Start: 'start', Trigger: 'trigger' }, + default: ({ children }: PropsWithChildren) =>
{children}
, +})) +vi.mock('../components/error-handle/error-handle-on-node', () => ({ + default: () =>
, +})) +vi.mock('../components/node-control', () => ({ + default: () =>
, +})) +vi.mock('../components/node-handle', () => ({ + NodeSourceHandle: () =>
, + NodeTargetHandle: () =>
, +})) +vi.mock('../components/node-resizer', () => ({ + default: () =>
, +})) +vi.mock('../components/retry/retry-on-node', () => ({ + default: () =>
, +})) +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
, +})) +vi.mock('@/app/components/workflow/nodes/tool/components/copy-id', () => ({ + default: ({ content }: { content: string }) =>
{content}
, +})) + +const createData = (overrides: Record = {}) => ({ + type: BlockEnum.Tool, + title: 'Node title', + desc: 'Node description', + selected: false, + width: 280, + height: 180, + provider_type: 'builtin', + provider_id: 'tool-1', + _runningStatus: undefined, + _singleRunningStatus: undefined, + ...overrides, +}) + +const toNodeData = (data: ReturnType) => data as CommonNodeType + +describe('BaseNode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHasNodeInspectVars.mockReturnValue(false) + mockUseNodeResizeObserver.mockReset() + mockUseNodePluginInstallation.mockReturnValue({ + shouldDim: false, + isChecking: false, + isMissing: false, + canInstall: false, + uniqueIdentifier: undefined, + }) + }) + + it('should render content, handles and description for a regular node', () => { + renderWorkflowComponent( + +
Body
+
, + ) + + expect(screen.getByText('Node title')).toBeInTheDocument() + expect(screen.getByText('Node description')).toBeInTheDocument() + expect(screen.getByTestId('node-control')).toBeInTheDocument() + expect(screen.getByTestId('node-source-handle')).toBeInTheDocument() + expect(screen.getByTestId('node-target-handle')).toBeInTheDocument() + }) + + it('should render entry nodes inside the entry container', () => { + renderWorkflowComponent( + +
Body
+
, + ) + + expect(screen.getByTestId('entry-node-container')).toBeInTheDocument() + }) + + it('should block interaction when plugin installation is required', () => { + mockUseNodePluginInstallation.mockReturnValue({ + shouldDim: false, + isChecking: false, + isMissing: true, + canInstall: true, + uniqueIdentifier: 'plugin-1', + }) + + renderWorkflowComponent( + +
Body
+
, + ) + + const overlay = screen.getByTestId('workflow-node-install-overlay') + expect(overlay).toBeInTheDocument() + fireEvent.click(overlay) + }) + + it('should render running status indicators for loop nodes', () => { + renderWorkflowComponent( + +
Loop body
+
, + ) + + expect(screen.getByText(/workflow\.nodes\.loop\.currentLoopCount/)).toBeInTheDocument() + expect(screen.getByTestId('node-resizer')).toBeInTheDocument() + }) + + it('should render an iteration node resizer and dimmed overlay', () => { + mockUseNodePluginInstallation.mockReturnValue({ + shouldDim: true, + isChecking: false, + isMissing: false, + canInstall: false, + uniqueIdentifier: undefined, + }) + + renderWorkflowComponent( + +
Iteration body
+
, + ) + + expect(screen.getByTestId('node-resizer')).toBeInTheDocument() + expect(screen.getByTestId('workflow-node-install-overlay')).toBeInTheDocument() + expect(mockHandleNodeIterationChildSizeChange).toHaveBeenCalledWith('node-1') + }) + + it('should trigger loop resize updates when the selected node is inside a loop', () => { + renderWorkflowComponent( + +
Loop body
+
, + ) + + expect(mockHandleNodeLoopChildSizeChange).toHaveBeenCalledWith('node-2') + expect(mockUseNodeResizeObserver).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx new file mode 100644 index 0000000000..02603e68c8 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx @@ -0,0 +1,58 @@ +import { renderHook } from '@testing-library/react' +import useNodeResizeObserver from '../use-node-resize-observer' + +describe('useNodeResizeObserver', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + it('should observe and disconnect when enabled with a mounted node ref', () => { + const observe = vi.fn() + const disconnect = vi.fn() + const onResize = vi.fn() + let resizeCallback: (() => void) | undefined + + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { + resizeCallback = callback + } + + observe = observe + disconnect = disconnect + unobserve = vi.fn() + }) + + const node = document.createElement('div') + const nodeRef = { current: node } + + const { unmount } = renderHook(() => useNodeResizeObserver({ + enabled: true, + nodeRef, + onResize, + })) + + expect(observe).toHaveBeenCalledWith(node) + resizeCallback?.() + expect(onResize).toHaveBeenCalledTimes(1) + + unmount() + expect(disconnect).toHaveBeenCalledTimes(1) + }) + + it('should do nothing when disabled', () => { + const observe = vi.fn() + + vi.stubGlobal('ResizeObserver', class { + observe = observe + disconnect = vi.fn() + unobserve = vi.fn() + }) + + renderHook(() => useNodeResizeObserver({ + enabled: false, + nodeRef: { current: document.createElement('div') }, + onResize: vi.fn(), + })) + + expect(observe).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx new file mode 100644 index 0000000000..49de788314 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -0,0 +1,410 @@ +import type { ComponentProps } from 'react' +import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { VarKindType } from '../../types' +import FormInputItem from '../form-input-item' + +const { + mockFetchDynamicOptions, + mockTriggerDynamicOptionsState, +} = vi.hoisted(() => ({ + mockFetchDynamicOptions: vi.fn(), + mockTriggerDynamicOptionsState: { + data: undefined as { options: FormOption[] } | undefined, + isLoading: false, + }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/service/use-plugins', () => ({ + useFetchDynamicOptions: () => ({ + mutateAsync: mockFetchDynamicOptions, + }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useTriggerPluginDynamicOptions: () => mockTriggerDynamicOptionsState, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ + default: ({ onSelect }: { onSelect: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ + default: ({ setModel }: { setModel: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({ + default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( + onChange(e.target.value)} /> + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( +