From 449d8c776878b5576458e7e90fe6873c2f27251b Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 25 Mar 2026 18:34:32 +0800 Subject: [PATCH] test(workflow-app): enhance unit tests for workflow components and hooks (#34065) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: lif <1835304752@qq.com> Co-authored-by: hjlarry Co-authored-by: Stephen Zhou Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Co-authored-by: Desel72 Co-authored-by: Renzo <170978465+RenzoMXD@users.noreply.github.com> Co-authored-by: Krishna Chaitanya Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../workflow-app/__tests__/index.spec.tsx | 350 ++++++++ .../workflow-app/__tests__/utils.spec.ts | 90 ++ .../__tests__/workflow-children.spec.tsx | 494 +++++++++++ .../__tests__/workflow-main.spec.tsx | 277 ++++++ .../__tests__/workflow-panel.spec.tsx | 214 +++++ .../__tests__/features-trigger.spec.tsx | 22 + .../hooks/__tests__/index.spec.ts | 18 + .../hooks/__tests__/use-DSL.spec.ts | 206 +++++ .../__tests__/use-auto-onboarding.spec.ts | 118 +++ .../use-available-nodes-meta-data.spec.ts | 49 ++ .../hooks/__tests__/use-configs-map.spec.ts | 40 + .../use-get-run-and-trace-url.spec.ts | 28 + .../__tests__/use-inspect-vars-crud.spec.ts | 44 + .../__tests__/use-nodes-sync-draft.spec.ts | 185 +++- .../hooks/__tests__/use-workflow-init.spec.ts | 109 ++- .../use-workflow-refresh-draft.spec.ts | 106 ++- .../use-workflow-run-callbacks.spec.ts | 451 ++++++++++ .../__tests__/use-workflow-run-utils.spec.ts | 431 ++++++++++ .../hooks/__tests__/use-workflow-run.spec.ts | 592 +++++++++++++ .../__tests__/use-workflow-start-run.spec.tsx | 391 +++++++++ .../__tests__/use-workflow-template.spec.ts | 82 ++ .../hooks/use-workflow-run-callbacks.ts | 470 +++++++++++ .../hooks/use-workflow-run-utils.ts | 443 ++++++++++ .../workflow-app/hooks/use-workflow-run.ts | 795 ++++-------------- web/app/components/workflow-app/index.tsx | 82 +- .../workflow/__tests__/workflow-slice.spec.ts | 44 + web/app/components/workflow-app/utils.ts | 107 +++ .../use-node-resize-observer.spec.tsx | 3 + .../before-run-form/__tests__/helpers.spec.ts | 10 + .../components/before-run-form/helpers.ts | 11 +- .../workflow-panel/__tests__/helpers.spec.tsx | 8 +- .../components/workflow-panel/helpers.tsx | 9 +- .../__tests__/generic-table.spec.tsx | 17 +- .../use-variable-modal-state.spec.ts | 31 +- .../__tests__/variable-modal.helpers.spec.ts | 15 + .../__tests__/variable-modal.spec.tsx | 26 +- .../components/use-variable-modal-state.ts | 18 +- .../components/variable-modal.helpers.ts | 8 +- .../components/variable-modal.sections.tsx | 5 +- web/eslint-suppressions.json | 5 +- 40 files changed, 5608 insertions(+), 796 deletions(-) create mode 100644 web/app/components/workflow-app/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow-app/__tests__/utils.spec.ts create mode 100644 web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx create mode 100644 web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx create mode 100644 web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx create mode 100644 web/app/components/workflow-app/hooks/__tests__/index.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-auto-onboarding.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-configs-map.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-get-run-and-trace-url.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-inspect-vars-crud.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-workflow-run-utils.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts create mode 100644 web/app/components/workflow-app/hooks/use-workflow-run-callbacks.ts create mode 100644 web/app/components/workflow-app/hooks/use-workflow-run-utils.ts create mode 100644 web/app/components/workflow-app/store/workflow/__tests__/workflow-slice.spec.ts create mode 100644 web/app/components/workflow-app/utils.ts 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/nodes/_base/__tests__/use-node-resize-observer.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx index 9ee377be4d..02603e68c8 100644 --- 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 @@ -2,6 +2,9 @@ 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() diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts index f4d456b6f6..961a56592a 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts @@ -57,6 +57,16 @@ describe('before-run-form helpers', () => { values: createValues({ query: '' }), })], [{}], t)).toContain('errorMsg.fieldRequired') + expect(getFormErrorMessage([createForm({ + inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile, required: true })], + values: createValues({ file: [] }), + })], [{}], t)).toContain('errorMsg.fieldRequired') + + expect(getFormErrorMessage([createForm({ + inputs: [createInput({ variable: 'files', label: 'Files', type: InputVarType.multiFiles, required: true })], + values: createValues({ files: [] }), + })], [{}], t)).toContain('errorMsg.fieldRequired') + expect(getFormErrorMessage([createForm({ inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile })], values: createValues({ file: { transferMethod: TransferMethod.local_file } }), diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts b/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts index 3e5cdf9a74..c0e08f64ec 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts @@ -56,7 +56,16 @@ export const getFormErrorMessage = ( const missingRequired = input.required && input.type !== InputVarType.checkbox && !(input.variable in existVarValuesInForm) - && (value === '' || value === undefined || value === null || (input.type === InputVarType.files && Array.isArray(value) && value.length === 0)) + && ( + value === '' || value === undefined || value === null + || ( + (input.type === InputVarType.files + || input.type === InputVarType.multiFiles + || input.type === InputVarType.singleFile) + && Array.isArray(value) + && value.length === 0 + ) + ) if (!errMsg && missingRequired) { errMsg = t('errorMsg.fieldRequired', { ns: 'workflow', field: typeof input.label === 'object' ? input.label.variable : input.label }) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx index 5eef8d3fa4..2929d0e47e 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx @@ -75,16 +75,12 @@ describe('workflow-panel helpers', () => { }) describe('custom run form fallback', () => { - it('should return a fallback message for unsupported custom run form nodes', () => { + it('should return null for unsupported custom run form nodes', () => { const form = getCustomRunForm({ ...createCustomRunFormProps({ type: BlockEnum.Tool }), }) - expect(form).toMatchObject({ - props: { - children: expect.arrayContaining(['Custom Run Form:', ' ', 'not found']), - }, - }) + expect(form).toBeNull() }) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx index c303bdc7f0..2e8e75d2a9 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx @@ -39,14 +39,7 @@ export const getCustomRunForm = (params: CustomRunFormProps): ReactNode => { case BlockEnum.DataSource: return default: - return ( -
- Custom Run Form: - {nodeType} - {' '} - not found -
- ) + return null } } diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx index 388ca255c8..a7fd56fbac 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' import GenericTable from '../generic-table' @@ -50,8 +50,19 @@ const advancedColumns = [ describe('GenericTable', () => { beforeEach(() => { vi.clearAllMocks() + vi.useRealTimers() }) + const selectOption = async (triggerName: string, optionName: string) => { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: triggerName })) + }) + + await act(async () => { + fireEvent.click(await screen.findByRole('option', { name: optionName })) + }) + } + it('should render an empty editable row and append a configured row when typing into the virtual row', async () => { const onChange = vi.fn() @@ -143,11 +154,11 @@ describe('GenericTable', () => { , ) - await user.click(screen.getByRole('button', { name: 'Choose method' })) - await user.click(await screen.findByRole('option', { name: 'POST' })) + await selectOption('Choose method', 'POST') await waitFor(() => { expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }]) + expect(screen.getByRole('button', { name: 'POST' })).toBeInTheDocument() }) onChange.mockClear() diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts index 61ad609e50..176650f0ed 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts @@ -90,6 +90,22 @@ describe('useVariableModalState', () => { ]) }) + it('should keep valid object rows when switching to json mode from form mode', () => { + const { result } = renderHook(() => useVariableModalState(createOptions())) + + act(() => { + result.current.handleTypeChange(ChatVarType.Object) + result.current.setObjectValue([ + { key: '', type: ChatVarType.String, value: undefined }, + { key: 'timeout', type: ChatVarType.Number, value: 30 }, + ]) + result.current.handleEditorChange(true) + }) + + expect(result.current.editInJSON).toBe(true) + expect(result.current.value).toEqual({ timeout: 30 }) + expect(result.current.editorContent).toBe(JSON.stringify({ timeout: 30 })) + }) it('should reset object form values when leaving empty json mode', () => { const { result } = renderHook(() => useVariableModalState(createOptions({ chatVar: { @@ -141,6 +157,19 @@ describe('useVariableModalState', () => { expect(result.current.editorContent).toBe(JSON.stringify(['True', 'False'])) }) + it('should preserve zero values when switching number arrays into json mode', () => { + const { result } = renderHook(() => useVariableModalState(createOptions())) + + act(() => { + result.current.handleTypeChange(ChatVarType.ArrayNumber) + result.current.setValue([0, 2, undefined]) + result.current.handleEditorChange(true) + }) + + expect(result.current.editInJSON).toBe(true) + expect(result.current.value).toEqual([0, 2]) + expect(result.current.editorContent).toBe(JSON.stringify([0, 2])) + }) it('should notify and stop saving when object keys are invalid', () => { const notify = vi.fn() const onSave = vi.fn() @@ -161,7 +190,7 @@ describe('useVariableModalState', () => { result.current.handleSave() }) - expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'object key can not be empty' }) + expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'chatVariable.modal.objectKeyRequired' }) expect(onSave).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts index 9e082265d6..86b46cc869 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts @@ -33,6 +33,10 @@ describe('variable-modal helpers', () => { { key: '', type: ChatVarType.Number, value: 1 }, ])).toEqual({ apiKey: 'secret' }) + expect(formatObjectValueFromList([ + { key: 'count', type: ChatVarType.Number, value: 0 }, + { key: 'label', type: ChatVarType.String, value: '' }, + ])).toEqual({ count: 0, label: null }) expect(formatChatVariableValue({ editInJSON: false, objectValue: [{ key: 'enabled', type: ChatVarType.String, value: 'true' }], @@ -54,6 +58,13 @@ describe('variable-modal helpers', () => { value: ['a', '', 'b'], })).toEqual(['a', 'b']) + expect(formatChatVariableValue({ + editInJSON: false, + objectValue: [], + type: ChatVarType.ArrayNumber, + value: [0, 1, undefined, null, ''] as unknown as Array, + })).toEqual([0, 1]) + expect(formatChatVariableValue({ editInJSON: false, objectValue: [], @@ -94,6 +105,10 @@ describe('variable-modal helpers', () => { type: ChatVarType.ArrayBoolean, })).toEqual([true, false, true, false]) + expect(() => parseEditorContent({ + content: '{"enabled":true}', + type: ChatVarType.ArrayBoolean, + })).toThrow('JSON array') expect(parseEditorContent({ content: '{"enabled":true}', type: ChatVarType.Object, diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx index 596383cb87..319e3803f4 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx @@ -80,7 +80,7 @@ describe('variable-modal', () => { await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'existing_name') await user.click(screen.getByText('common.operation.save')) - expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('name is existed') + expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.chatVariable.modal.name"}') expect(onSave).not.toHaveBeenCalled() }) @@ -100,8 +100,10 @@ describe('variable-modal', () => { expect(screen.getByDisplayValue('secret')).toBeInTheDocument() expect(screen.getByDisplayValue('30')).toBeInTheDocument() + const timeoutInput = screen.getByDisplayValue('30') as HTMLInputElement await user.clear(screen.getByDisplayValue('secret')) - await user.type(screen.getByDisplayValue('30'), '5') + await user.clear(timeoutInput) + await user.type(timeoutInput, '5') await user.click(screen.getByText('common.operation.save')) expect(onSave).toHaveBeenCalledWith({ @@ -110,7 +112,7 @@ describe('variable-modal', () => { value_type: ChatVarType.Object, value: { apiKey: null, - timeout: 305, + timeout: 5, }, description: 'settings', }) @@ -195,4 +197,22 @@ describe('variable-modal', () => { description: '', }) }) + + it('should keep the number input empty while editing after the user clears it', async () => { + const user = userEvent.setup() + renderVariableModal({ + chatVar: { + id: 'var-4', + name: 'timeout', + description: '', + value_type: ChatVarType.Number, + value: 3, + }, + }) + + const input = screen.getByDisplayValue('3') as HTMLInputElement + await user.clear(input) + + expect(input.value).toBe('') + }) }) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts b/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts index ecc8af6432..07619029a3 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts +++ b/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts @@ -108,7 +108,7 @@ export const useVariableModalState = ({ if (prev.type === ChatVarType.Object) { if (nextEditInJSON) { - const nextValue = !prev.objectValue[0].key ? undefined : formatObjectValueFromList(prev.objectValue) + const nextValue = prev.objectValue.some(item => item.key) ? formatObjectValueFromList(prev.objectValue) : undefined nextState.value = nextValue nextState.editorContent = JSON.stringify(nextValue) return nextState @@ -133,8 +133,11 @@ export const useVariableModalState = ({ if (prev.type === ChatVarType.ArrayString || prev.type === ChatVarType.ArrayNumber) { if (nextEditInJSON) { - const nextValue = (Array.isArray(prev.value) && prev.value.length && prev.value.filter(Boolean).length) - ? prev.value.filter(Boolean) + const compactValues = Array.isArray(prev.value) + ? prev.value.filter(item => item !== null && item !== undefined && item !== '') + : [] + const nextValue = compactValues.length + ? compactValues : undefined nextState.value = nextValue if (!prev.editorContent) @@ -181,12 +184,15 @@ export const useVariableModalState = ({ return if (!chatVar && conversationVariables.some(item => item.name === state.name)) { - notify({ type: 'error', message: 'name is existed' }) + notify({ + type: 'error', + message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: t('chatVariable.modal.name', { ns: 'workflow' }) }), + }) return } - if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && !!item.value)) { - notify({ type: 'error', message: 'object key can not be empty' }) + if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && item.value !== undefined && item.value !== '')) { + notify({ type: 'error', message: t('chatVariable.modal.objectKeyRequired', { ns: 'workflow' }) }) return } diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts index 944b197e19..8307cbe80b 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts @@ -72,7 +72,7 @@ export const buildObjectValueItems = (chatVar?: ConversationVariable): ObjectVal export const formatObjectValueFromList = (list: ObjectValueItem[]) => { return list.reduce>((acc, curr) => { if (curr.key) - acc[curr.key] = curr.value || null + acc[curr.key] = curr.value === '' || curr.value === undefined ? null : curr.value return acc }, {}) } @@ -88,6 +88,8 @@ export const formatChatVariableValue = ({ type: ChatVarType value: unknown }) => { + const compactArrayValue = (items: unknown[]) => + items.filter(item => item !== null && item !== undefined && item !== '') switch (type) { case ChatVarTypeEnum.String: return value || '' @@ -100,7 +102,7 @@ export const formatChatVariableValue = ({ case ChatVarTypeEnum.ArrayString: case ChatVarTypeEnum.ArrayNumber: case ChatVarTypeEnum.ArrayObject: - return Array.isArray(value) ? value.filter(Boolean) : [] + return Array.isArray(value) ? compactArrayValue(value) : [] case ChatVarTypeEnum.ArrayBoolean: return value || [] } @@ -151,6 +153,8 @@ export const parseEditorContent = ({ if (type !== ChatVarTypeEnum.ArrayBoolean) return parsed + if (!Array.isArray(parsed)) + throw new TypeError('ArrayBoolean editor content must be a JSON array') return parsed .map((item: string | boolean) => { if (item === 'True' || item === 'true' || item === true) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx index dd7e69bb34..6b31e024b4 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx @@ -138,7 +138,10 @@ export const ValueSection = ({ onArrayChange([Number(e.target.value)])} + onChange={(e) => { + const rawValue = e.target.value + onArrayChange([rawValue === '' ? undefined : Number(rawValue)]) + }} type="number" /> )} diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 74642307af..e28d915e66 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -6416,11 +6416,8 @@ } }, "app/components/workflow-app/hooks/use-workflow-run.ts": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { - "count": 13 + "count": 5 } }, "app/components/workflow-app/hooks/use-workflow-template.ts": {