From 2b0b3e332154e9f8262e28a0631191a30858afa5 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 25 Mar 2026 13:25:00 +0800 Subject: [PATCH] test(workflow-app): enhance unit tests for workflow components and hooks - Added tests to ensure proper handling of unavailable trigger data and empty payloads in WorkflowApp. - Implemented tests for WorkflowChildren to verify behavior with various node configurations and event handling. - Expanded useWorkflowRun and useWorkflowStartRun tests to cover additional scenarios, including input validation and trigger execution conditions. - Improved test coverage for utility functions related to workflow run handling and debugging. --- .../workflow-app/__tests__/index.spec.tsx | 70 +++++ .../__tests__/workflow-children.spec.tsx | 96 +++++- .../__tests__/workflow-main.spec.tsx | 46 +++ .../hooks/__tests__/use-DSL.spec.ts | 48 +++ .../use-workflow-run-callbacks.spec.ts | 280 ++++++++++++++++++ .../__tests__/use-workflow-run-utils.spec.ts | 193 ++++++++++++ .../hooks/__tests__/use-workflow-run.spec.ts | 203 ++++++++++++- .../__tests__/use-workflow-start-run.spec.tsx | 111 +++++++ 8 files changed, 1043 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow-app/__tests__/index.spec.tsx b/web/app/components/workflow-app/__tests__/index.spec.tsx index 9a80f627d2..880a62ca8d 100644 --- a/web/app/components/workflow-app/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/__tests__/index.spec.tsx @@ -251,6 +251,13 @@ describe('WorkflowApp', () => { }) }) + 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({ @@ -277,4 +284,67 @@ describe('WorkflowApp', () => { 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/components/__tests__/workflow-children.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx index 9392fc18eb..898afffb69 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx @@ -228,6 +228,17 @@ vi.mock('@/app/components/workflow-app/components/workflow-onboarding-modal', ()
+ +
), })) @@ -319,6 +349,21 @@ describe('WorkflowChildren', () => { 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 = { @@ -344,7 +389,7 @@ describe('WorkflowChildren', () => { render() - await user.click(await screen.findByRole('button', { name: /select-start-node/i })) + await user.click(await screen.findByRole('button', { name: /^select-start-node$/i })) expect(lastGenerateNodeInput).toMatchObject({ data: { @@ -364,6 +409,30 @@ describe('WorkflowChildren', () => { 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 = { @@ -373,7 +442,7 @@ describe('WorkflowChildren', () => { render() - await user.click(await screen.findByRole('button', { name: /select-trigger-plugin/i })) + await user.click(await screen.findByRole('button', { name: /^select-trigger-plugin$/i })) expect(lastGenerateNodeInput).toMatchObject({ data: { @@ -399,4 +468,27 @@ describe('WorkflowChildren', () => { }, }) }) + + 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 index 1cf8e6e94c..250b87069f 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx @@ -94,6 +94,20 @@ vi.mock('@/app/components/workflow', () => ({ > update-workflow-data + + {children} ) @@ -195,6 +209,38 @@ describe('WorkflowMain', () => { 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( { })) }) + 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 }) @@ -111,6 +125,22 @@ describe('useDSL', () => { 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')) @@ -128,6 +158,24 @@ describe('useDSL', () => { }) }) + 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) 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 index 825ba5b8fc..880d5e56e6 100644 --- 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 @@ -43,6 +43,27 @@ const createHandlers = () => ({ 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() @@ -168,4 +189,263 @@ describe('useWorkflowRun callbacks helpers', () => { 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 index 65c990e02a..a83d2f55ee 100644 --- 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 @@ -76,6 +76,21 @@ describe('useWorkflowRun utils', () => { 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', () => { @@ -140,6 +155,10 @@ describe('useWorkflowRun utils', () => { 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: { @@ -177,6 +196,60 @@ describe('useWorkflowRun utils', () => { }) }) + 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() @@ -230,6 +303,126 @@ describe('useWorkflowRun utils', () => { 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) 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 index 9348527ff6..7b54598774 100644 --- 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 @@ -1,4 +1,5 @@ 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' @@ -74,7 +75,10 @@ const mocks = vi.hoisted(() => { 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(), @@ -125,7 +129,7 @@ vi.mock('@/app/components/base/amplitude', () => ({ vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ AudioPlayerManager: { getInstance: () => ({ - getAudioPlayer: vi.fn(), + getAudioPlayer: mocks.mockGetAudioPlayer, resetMsgId: mocks.mockResetMsgId, }), }, @@ -196,6 +200,22 @@ vi.mock('../use-nodes-sync-draft', () => ({ }), })) +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' }], @@ -227,6 +247,12 @@ describe('useWorkflowRun', () => { ]) 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) => { @@ -316,9 +342,143 @@ describe('useWorkflowRun', () => { ) }) - it('should stop workflow runs by task id or by aborting active debug controllers', () => { + 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') }) @@ -339,6 +499,12 @@ describe('useWorkflowRun', () => { 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('') @@ -348,6 +514,7 @@ describe('useWorkflowRun', () => { expect(pluginAbort).toHaveBeenCalled() expect(scheduleAbort).toHaveBeenCalled() expect(allTriggersAbort).toHaveBeenCalled() + expect(refAbortSpy).toHaveBeenCalled() }) it('should restore published workflow graph, features, and environment variables', () => { @@ -390,4 +557,36 @@ describe('useWorkflowRun', () => { }) 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 index 0b3cc4c162..80b5c6e660 100644 --- 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 @@ -130,6 +130,29 @@ describe('useWorkflowStartRun', () => { 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, @@ -196,6 +219,82 @@ describe('useWorkflowStartRun', () => { 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', @@ -261,6 +360,18 @@ describe('useWorkflowStartRun', () => { ) }) + 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)