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)