diff --git a/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx
new file mode 100644
index 0000000000..61e5410aac
--- /dev/null
+++ b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx
@@ -0,0 +1,260 @@
+import { render, screen } from '@testing-library/react'
+import CandidateNodeMain from '../candidate-node-main'
+import { CUSTOM_NODE } from '../constants'
+import { CUSTOM_NOTE_NODE } from '../note-node/constants'
+import { BlockEnum } from '../types'
+import { createNode } from './fixtures'
+
+const mockUseEventListener = vi.hoisted(() => vi.fn())
+const mockUseStoreApi = vi.hoisted(() => vi.fn())
+const mockUseReactFlow = vi.hoisted(() => vi.fn())
+const mockUseViewport = vi.hoisted(() => vi.fn())
+const mockUseStore = vi.hoisted(() => vi.fn())
+const mockUseWorkflowStore = vi.hoisted(() => vi.fn())
+const mockUseHooks = vi.hoisted(() => vi.fn())
+const mockCustomNode = vi.hoisted(() => vi.fn())
+const mockCustomNoteNode = vi.hoisted(() => vi.fn())
+const mockGetIterationStartNode = vi.hoisted(() => vi.fn())
+const mockGetLoopStartNode = vi.hoisted(() => vi.fn())
+
+vi.mock('ahooks', () => ({
+ useEventListener: (...args: unknown[]) => mockUseEventListener(...args),
+}))
+
+vi.mock('reactflow', () => ({
+ useStoreApi: () => mockUseStoreApi(),
+ useReactFlow: () => mockUseReactFlow(),
+ useViewport: () => mockUseViewport(),
+ Position: {
+ Left: 'left',
+ Right: 'right',
+ },
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { mousePosition: {
+ pageX: number
+ pageY: number
+ elementX: number
+ elementY: number
+ } }) => unknown) => mockUseStore(selector),
+ useWorkflowStore: () => mockUseWorkflowStore(),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesInteractions: () => mockUseHooks().useNodesInteractions(),
+ useNodesSyncDraft: () => mockUseHooks().useNodesSyncDraft(),
+ useWorkflowHistory: () => mockUseHooks().useWorkflowHistory(),
+ useAutoGenerateWebhookUrl: () => mockUseHooks().useAutoGenerateWebhookUrl(),
+ WorkflowHistoryEvent: {
+ NodeAdd: 'NodeAdd',
+ NoteAdd: 'NoteAdd',
+ },
+}))
+
+vi.mock('@/app/components/workflow/nodes', () => ({
+ __esModule: true,
+ default: (props: { id: string }) => {
+ mockCustomNode(props)
+ return
{props.id}
+ },
+}))
+
+vi.mock('@/app/components/workflow/note-node', () => ({
+ __esModule: true,
+ default: (props: { id: string }) => {
+ mockCustomNoteNode(props)
+ return {props.id}
+ },
+}))
+
+vi.mock('@/app/components/workflow/utils', () => ({
+ getIterationStartNode: (...args: unknown[]) => mockGetIterationStartNode(...args),
+ getLoopStartNode: (...args: unknown[]) => mockGetLoopStartNode(...args),
+}))
+
+describe('CandidateNodeMain', () => {
+ const mockSetNodes = vi.fn()
+ const mockHandleNodeSelect = vi.fn()
+ const mockSaveStateToHistory = vi.fn()
+ const mockHandleSyncWorkflowDraft = vi.fn()
+ const mockAutoGenerateWebhookUrl = vi.fn()
+ const mockWorkflowStoreSetState = vi.fn()
+ const createNodesInteractions = () => ({
+ handleNodeSelect: mockHandleNodeSelect,
+ })
+ const createWorkflowHistory = () => ({
+ saveStateToHistory: mockSaveStateToHistory,
+ })
+ const createNodesSyncDraft = () => ({
+ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
+ })
+ const createAutoGenerateWebhookUrl = () => mockAutoGenerateWebhookUrl
+ const eventHandlers: Partial void }) => void>> = {}
+ let nodes = [createNode({ id: 'existing-node' })]
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ nodes = [createNode({ id: 'existing-node' })]
+ eventHandlers.click = undefined
+ eventHandlers.contextmenu = undefined
+
+ mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu', handler: (event: { preventDefault: () => void }) => void) => {
+ eventHandlers[event] = handler
+ })
+ mockUseStoreApi.mockReturnValue({
+ getState: () => ({
+ getNodes: () => nodes,
+ setNodes: mockSetNodes,
+ }),
+ })
+ mockUseReactFlow.mockReturnValue({
+ screenToFlowPosition: ({ x, y }: { x: number, y: number }) => ({ x: x + 10, y: y + 20 }),
+ })
+ mockUseViewport.mockReturnValue({ zoom: 1.5 })
+ mockUseStore.mockImplementation((selector: (state: { mousePosition: {
+ pageX: number
+ pageY: number
+ elementX: number
+ elementY: number
+ } }) => unknown) => selector({
+ mousePosition: {
+ pageX: 100,
+ pageY: 200,
+ elementX: 30,
+ elementY: 40,
+ },
+ }))
+ mockUseWorkflowStore.mockReturnValue({
+ setState: mockWorkflowStoreSetState,
+ })
+ mockUseHooks.mockReturnValue({
+ useNodesInteractions: createNodesInteractions,
+ useWorkflowHistory: createWorkflowHistory,
+ useNodesSyncDraft: createNodesSyncDraft,
+ useAutoGenerateWebhookUrl: createAutoGenerateWebhookUrl,
+ })
+ mockHandleSyncWorkflowDraft.mockImplementation((_isSync: boolean, _force: boolean, options?: { onSuccess?: () => void }) => {
+ options?.onSuccess?.()
+ })
+ mockGetIterationStartNode.mockReturnValue(createNode({ id: 'iteration-start' }))
+ mockGetLoopStartNode.mockReturnValue(createNode({ id: 'loop-start' }))
+ })
+
+ it('should render the candidate node and commit a webhook node on click', () => {
+ const candidateNode = createNode({
+ id: 'candidate-webhook',
+ type: CUSTOM_NODE,
+ data: {
+ type: BlockEnum.TriggerWebhook,
+ title: 'Webhook Candidate',
+ _isCandidate: true,
+ },
+ })
+
+ const { container } = render()
+
+ expect(screen.getByTestId('candidate-custom-node')).toHaveTextContent('candidate-webhook')
+ expect(container.firstChild).toHaveStyle({
+ left: '30px',
+ top: '40px',
+ transform: 'scale(1.5)',
+ })
+
+ eventHandlers.click?.({ preventDefault: vi.fn() })
+
+ expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([
+ expect.objectContaining({ id: 'existing-node' }),
+ expect.objectContaining({
+ id: 'candidate-webhook',
+ position: { x: 110, y: 220 },
+ data: expect.objectContaining({ _isCandidate: false }),
+ }),
+ ]))
+ expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeAdd', { nodeId: 'candidate-webhook' })
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
+ expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true, expect.objectContaining({
+ onSuccess: expect.any(Function),
+ }))
+ expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('candidate-webhook')
+ expect(mockHandleNodeSelect).not.toHaveBeenCalled()
+ })
+
+ it('should save note candidates as notes and select the inserted note', () => {
+ const candidateNode = createNode({
+ id: 'candidate-note',
+ type: CUSTOM_NOTE_NODE,
+ data: {
+ type: BlockEnum.Code,
+ title: 'Note Candidate',
+ _isCandidate: true,
+ },
+ })
+
+ render()
+
+ expect(screen.getByTestId('candidate-note-node')).toHaveTextContent('candidate-note')
+
+ eventHandlers.click?.({ preventDefault: vi.fn() })
+
+ expect(mockSaveStateToHistory).toHaveBeenCalledWith('NoteAdd', { nodeId: 'candidate-note' })
+ expect(mockHandleNodeSelect).toHaveBeenCalledWith('candidate-note')
+ })
+
+ it('should append iteration and loop start helper nodes for control-flow candidates', () => {
+ const iterationNode = createNode({
+ id: 'candidate-iteration',
+ type: CUSTOM_NODE,
+ data: {
+ type: BlockEnum.Iteration,
+ title: 'Iteration Candidate',
+ _isCandidate: true,
+ },
+ })
+ const loopNode = createNode({
+ id: 'candidate-loop',
+ type: CUSTOM_NODE,
+ data: {
+ type: BlockEnum.Loop,
+ title: 'Loop Candidate',
+ _isCandidate: true,
+ },
+ })
+
+ const { rerender } = render()
+
+ eventHandlers.click?.({ preventDefault: vi.fn() })
+ expect(mockGetIterationStartNode).toHaveBeenCalledWith('candidate-iteration')
+ expect(mockSetNodes.mock.calls[0][0]).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: 'candidate-iteration' }),
+ expect.objectContaining({ id: 'iteration-start' }),
+ ]))
+
+ rerender()
+ eventHandlers.click?.({ preventDefault: vi.fn() })
+
+ expect(mockGetLoopStartNode).toHaveBeenCalledWith('candidate-loop')
+ expect(mockSetNodes.mock.calls[1][0]).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: 'candidate-loop' }),
+ expect.objectContaining({ id: 'loop-start' }),
+ ]))
+ })
+
+ it('should clear the candidate node on contextmenu', () => {
+ const candidateNode = createNode({
+ id: 'candidate-context',
+ type: CUSTOM_NODE,
+ data: {
+ type: BlockEnum.Code,
+ title: 'Context Candidate',
+ _isCandidate: true,
+ },
+ })
+
+ render()
+
+ eventHandlers.contextmenu?.({ preventDefault: vi.fn() })
+
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
+ })
+})
diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx
new file mode 100644
index 0000000000..f8ff9a1a0e
--- /dev/null
+++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx
@@ -0,0 +1,235 @@
+import type { ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { Position } from 'reactflow'
+import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
+import CustomEdge from '../custom-edge'
+import { BlockEnum, NodeRunningStatus } from '../types'
+
+const mockUseAvailableBlocks = vi.hoisted(() => vi.fn())
+const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
+const mockBlockSelector = vi.hoisted(() => vi.fn())
+const mockGradientRender = vi.hoisted(() => vi.fn())
+
+vi.mock('reactflow', () => ({
+ BaseEdge: (props: {
+ id: string
+ path: string
+ style: {
+ stroke: string
+ strokeWidth: number
+ opacity: number
+ strokeDasharray?: string
+ }
+ }) => (
+
+ ),
+ EdgeLabelRenderer: ({ children }: { children?: ReactNode }) => {children}
,
+ getBezierPath: () => ['M 0 0', 24, 48],
+ Position: {
+ Right: 'right',
+ Left: 'left',
+ },
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useAvailableBlocks: (...args: unknown[]) => mockUseAvailableBlocks(...args),
+ useNodesInteractions: () => mockUseNodesInteractions(),
+}))
+
+vi.mock('@/app/components/workflow/block-selector', () => ({
+ __esModule: true,
+ default: (props: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSelect: (nodeType: string, pluginDefaultValue?: Record) => void
+ availableBlocksTypes: string[]
+ triggerClassName?: () => string
+ }) => {
+ mockBlockSelector(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/custom-edge-linear-gradient-render', () => ({
+ __esModule: true,
+ default: (props: {
+ id: string
+ startColor: string
+ stopColor: string
+ }) => {
+ mockGradientRender(props)
+ return {props.id}
+ },
+}))
+
+describe('CustomEdge', () => {
+ const mockHandleNodeAdd = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseNodesInteractions.mockReturnValue({
+ handleNodeAdd: mockHandleNodeAdd,
+ })
+ mockUseAvailableBlocks.mockImplementation((nodeType: BlockEnum) => {
+ if (nodeType === BlockEnum.Code)
+ return { availablePrevBlocks: ['code', 'llm'] }
+
+ return { availableNextBlocks: ['llm', 'tool'] }
+ })
+ })
+
+ it('should render a gradient edge and insert a node between the source and target', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('edge-gradient')).toHaveTextContent('edge-1')
+ expect(mockGradientRender).toHaveBeenCalledWith(expect.objectContaining({
+ id: 'edge-1',
+ startColor: 'var(--color-workflow-link-line-success-handle)',
+ stopColor: 'var(--color-workflow-link-line-error-handle)',
+ }))
+ expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'url(#edge-1)')
+ expect(screen.getByTestId('base-edge')).toHaveAttribute('data-opacity', '0.3')
+ expect(screen.getByTestId('base-edge')).toHaveAttribute('data-dasharray', '8 8')
+ expect(screen.getByTestId('block-selector')).toHaveTextContent('llm')
+ expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({
+ transform: 'translate(-50%, -50%) translate(24px, 48px)',
+ opacity: '0.7',
+ })
+
+ fireEvent.click(screen.getByTestId('block-selector'))
+
+ expect(mockHandleNodeAdd).toHaveBeenCalledWith(
+ {
+ nodeType: 'llm',
+ pluginDefaultValue: { provider: 'openai' },
+ },
+ {
+ prevNodeId: 'source-node',
+ prevNodeSourceHandle: 'source',
+ nextNodeId: 'target-node',
+ nextNodeTargetHandle: 'target',
+ },
+ )
+ })
+
+ it('should prefer the running stroke color when the edge is selected', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-handle)')
+ })
+
+ it('should use the fail-branch running color while the connected node is hovering', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-failure-handle)')
+ })
+
+ it('should fall back to the default edge color when no highlight state is active', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)')
+ expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all')
+ })
+})
diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx
new file mode 100644
index 0000000000..7418b7f313
--- /dev/null
+++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx
@@ -0,0 +1,114 @@
+import type { Node } from '../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import NodeContextmenu from '../node-contextmenu'
+
+const mockUseClickAway = vi.hoisted(() => vi.fn())
+const mockUseNodes = vi.hoisted(() => vi.fn())
+const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
+const mockUseStore = vi.hoisted(() => vi.fn())
+const mockPanelOperatorPopup = vi.hoisted(() => vi.fn())
+
+vi.mock('ahooks', () => ({
+ useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
+}))
+
+vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
+ __esModule: true,
+ default: () => mockUseNodes(),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ usePanelInteractions: () => mockUsePanelInteractions(),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({
+ __esModule: true,
+ default: (props: {
+ id: string
+ data: Node['data']
+ showHelpLink: boolean
+ onClosePopup: () => void
+ }) => {
+ mockPanelOperatorPopup(props)
+ return (
+
+ )
+ },
+}))
+
+describe('NodeContextmenu', () => {
+ const mockHandleNodeContextmenuCancel = vi.fn()
+ let nodeMenu: { nodeId: string, left: number, top: number } | undefined
+ let nodes: Node[]
+ let clickAwayHandler: (() => void) | undefined
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ nodeMenu = undefined
+ nodes = [{
+ id: 'node-1',
+ type: 'custom',
+ position: { x: 0, y: 0 },
+ data: {
+ title: 'Node 1',
+ desc: '',
+ type: 'code' as never,
+ },
+ } as Node]
+ clickAwayHandler = undefined
+
+ mockUseClickAway.mockImplementation((handler: () => void) => {
+ clickAwayHandler = handler
+ })
+ mockUseNodes.mockImplementation(() => nodes)
+ mockUsePanelInteractions.mockReturnValue({
+ handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel,
+ })
+ mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu }))
+ })
+
+ it('should stay hidden when the node menu is absent', () => {
+ render()
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
+ })
+
+ it('should stay hidden when the referenced node cannot be found', () => {
+ nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 }
+
+ render()
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
+ })
+
+ it('should render the popup at the stored position and close on popup/click-away actions', () => {
+ nodeMenu = { nodeId: 'node-1', left: 80, top: 120 }
+ const { container } = render()
+
+ expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1')
+ expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({
+ id: 'node-1',
+ data: expect.objectContaining({ title: 'Node 1' }),
+ showHelpLink: true,
+ }))
+ expect(container.firstChild).toHaveStyle({
+ left: '80px',
+ top: '120px',
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+ clickAwayHandler?.()
+
+ expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx
new file mode 100644
index 0000000000..914c1be617
--- /dev/null
+++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx
@@ -0,0 +1,151 @@
+import type { ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import PanelContextmenu from '../panel-contextmenu'
+
+const mockUseClickAway = vi.hoisted(() => vi.fn())
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockUseStore = vi.hoisted(() => vi.fn())
+const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
+const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
+const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn())
+const mockUseOperator = vi.hoisted(() => vi.fn())
+const mockUseDSL = vi.hoisted(() => vi.fn())
+
+vi.mock('ahooks', () => ({
+ useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: {
+ panelMenu?: { left: number, top: number }
+ clipboardElements: unknown[]
+ setShowImportDSLModal: (visible: boolean) => void
+ }) => unknown) => mockUseStore(selector),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesInteractions: () => mockUseNodesInteractions(),
+ usePanelInteractions: () => mockUsePanelInteractions(),
+ useWorkflowStartRun: () => mockUseWorkflowStartRun(),
+ useDSL: () => mockUseDSL(),
+}))
+
+vi.mock('@/app/components/workflow/operator/hooks', () => ({
+ useOperator: () => mockUseOperator(),
+}))
+
+vi.mock('@/app/components/workflow/operator/add-block', () => ({
+ __esModule: true,
+ default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => (
+ {renderTrigger()}
+ ),
+}))
+
+vi.mock('@/app/components/base/divider', () => ({
+ __esModule: true,
+ default: ({ className }: { className?: string }) => ,
+}))
+
+vi.mock('@/app/components/workflow/shortcuts-name', () => ({
+ __esModule: true,
+ default: ({ keys }: { keys: string[] }) => {keys.join('+')},
+}))
+
+describe('PanelContextmenu', () => {
+ const mockHandleNodesPaste = vi.fn()
+ const mockHandlePaneContextmenuCancel = vi.fn()
+ const mockHandleStartWorkflowRun = vi.fn()
+ const mockHandleAddNote = vi.fn()
+ const mockExportCheck = vi.fn()
+ const mockSetShowImportDSLModal = vi.fn()
+ let panelMenu: { left: number, top: number } | undefined
+ let clipboardElements: unknown[]
+ let clickAwayHandler: (() => void) | undefined
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ panelMenu = undefined
+ clipboardElements = []
+ clickAwayHandler = undefined
+
+ mockUseClickAway.mockImplementation((handler: () => void) => {
+ clickAwayHandler = handler
+ })
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ mockUseStore.mockImplementation((selector: (state: {
+ panelMenu?: { left: number, top: number }
+ clipboardElements: unknown[]
+ setShowImportDSLModal: (visible: boolean) => void
+ }) => unknown) => selector({
+ panelMenu,
+ clipboardElements,
+ setShowImportDSLModal: mockSetShowImportDSLModal,
+ }))
+ mockUseNodesInteractions.mockReturnValue({
+ handleNodesPaste: mockHandleNodesPaste,
+ })
+ mockUsePanelInteractions.mockReturnValue({
+ handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
+ })
+ mockUseWorkflowStartRun.mockReturnValue({
+ handleStartWorkflowRun: mockHandleStartWorkflowRun,
+ })
+ mockUseOperator.mockReturnValue({
+ handleAddNote: mockHandleAddNote,
+ })
+ mockUseDSL.mockReturnValue({
+ exportCheck: mockExportCheck,
+ })
+ })
+
+ it('should stay hidden when the panel menu is absent', () => {
+ render()
+
+ expect(screen.queryByTestId('add-block')).not.toBeInTheDocument()
+ })
+
+ it('should keep paste disabled when the clipboard is empty', () => {
+ panelMenu = { left: 24, top: 48 }
+
+ render()
+
+ fireEvent.click(screen.getByText('common.pasteHere'))
+
+ expect(mockHandleNodesPaste).not.toHaveBeenCalled()
+ expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled()
+ })
+
+ it('should render actions, position the menu, and execute each action', () => {
+ panelMenu = { left: 24, top: 48 }
+ clipboardElements = [{ id: 'copied-node' }]
+ const { container } = render()
+
+ expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock')
+ expect(screen.getByTestId('shortcut-alt-r')).toHaveTextContent('alt+r')
+ expect(screen.getByTestId('shortcut-ctrl-v')).toHaveTextContent('ctrl+v')
+ expect(container.firstChild).toHaveStyle({
+ left: '24px',
+ top: '48px',
+ })
+
+ fireEvent.click(screen.getByText('nodes.note.addNote'))
+ fireEvent.click(screen.getByText('common.run'))
+ fireEvent.click(screen.getByText('common.pasteHere'))
+ fireEvent.click(screen.getByText('export'))
+ fireEvent.click(screen.getByText('common.importDSL'))
+ clickAwayHandler?.()
+
+ expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
+ expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1)
+ expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1)
+ expect(mockExportCheck).toHaveBeenCalledTimes(1)
+ expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true)
+ expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4)
+ })
+})
diff --git a/web/app/components/workflow/help-line/__tests__/index.spec.tsx b/web/app/components/workflow/help-line/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..f58c9c5d02
--- /dev/null
+++ b/web/app/components/workflow/help-line/__tests__/index.spec.tsx
@@ -0,0 +1,61 @@
+import { render } from '@testing-library/react'
+import HelpLine from '../index'
+
+const mockUseViewport = vi.hoisted(() => vi.fn())
+const mockUseStore = vi.hoisted(() => vi.fn())
+
+vi.mock('reactflow', () => ({
+ useViewport: () => mockUseViewport(),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: {
+ helpLineHorizontal?: { top: number, left: number, width: number }
+ helpLineVertical?: { top: number, left: number, height: number }
+ }) => unknown) => mockUseStore(selector),
+}))
+
+describe('HelpLine', () => {
+ let helpLineHorizontal: { top: number, left: number, width: number } | undefined
+ let helpLineVertical: { top: number, left: number, height: number } | undefined
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ helpLineHorizontal = undefined
+ helpLineVertical = undefined
+
+ mockUseViewport.mockReturnValue({ x: 10, y: 20, zoom: 2 })
+ mockUseStore.mockImplementation((selector: (state: {
+ helpLineHorizontal?: { top: number, left: number, width: number }
+ helpLineVertical?: { top: number, left: number, height: number }
+ }) => unknown) => selector({
+ helpLineHorizontal,
+ helpLineVertical,
+ }))
+ })
+
+ it('should render nothing when both help lines are absent', () => {
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should render the horizontal and vertical guide lines using viewport offsets and zoom', () => {
+ helpLineHorizontal = { top: 30, left: 40, width: 50 }
+ helpLineVertical = { top: 60, left: 70, height: 80 }
+
+ const { container } = render()
+ const [horizontal, vertical] = Array.from(container.querySelectorAll('div'))
+
+ expect(horizontal).toHaveStyle({
+ top: '80px',
+ left: '90px',
+ width: '100px',
+ })
+ expect(vertical).toHaveStyle({
+ top: '140px',
+ left: '150px',
+ height: '160px',
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts
new file mode 100644
index 0000000000..5811f14a60
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts
@@ -0,0 +1,171 @@
+import type { ModelConfig, VisionSetting } from '@/app/components/workflow/types'
+import { act, renderHook } from '@testing-library/react'
+import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { Resolution } from '@/types/app'
+import useConfigVision from '../use-config-vision'
+
+const mockUseTextGenerationCurrentProviderAndModelAndModelList = vi.hoisted(() => vi.fn())
+const mockUseIsChatMode = vi.hoisted(() => vi.fn())
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) =>
+ mockUseTextGenerationCurrentProviderAndModelAndModelList(...args),
+}))
+
+vi.mock('../use-workflow', () => ({
+ useIsChatMode: () => mockUseIsChatMode(),
+}))
+
+const createModel = (overrides: Partial = {}): ModelConfig => ({
+ provider: 'openai',
+ name: 'gpt-4o',
+ mode: 'chat',
+ completion_params: [],
+ ...overrides,
+})
+
+const createVisionPayload = (overrides: Partial<{ enabled: boolean, configs?: VisionSetting }> = {}) => ({
+ enabled: false,
+ ...overrides,
+})
+
+describe('useConfigVision', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseIsChatMode.mockReturnValue(false)
+ mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
+ currentModel: {
+ features: [],
+ },
+ })
+ })
+
+ it('should expose vision capability and enable default chat configs for vision models', () => {
+ const onChange = vi.fn()
+ mockUseIsChatMode.mockReturnValue(true)
+ mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
+ currentModel: {
+ features: [ModelFeatureEnum.vision],
+ },
+ })
+
+ const { result } = renderHook(() => useConfigVision(createModel(), {
+ payload: createVisionPayload(),
+ onChange,
+ }))
+
+ expect(result.current.isVisionModel).toBe(true)
+
+ act(() => {
+ result.current.handleVisionResolutionEnabledChange(true)
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ enabled: true,
+ configs: {
+ detail: Resolution.high,
+ variable_selector: ['sys', 'files'],
+ },
+ })
+ })
+
+ it('should clear configs when disabling vision resolution', () => {
+ const onChange = vi.fn()
+
+ const { result } = renderHook(() => useConfigVision(createModel(), {
+ payload: createVisionPayload({
+ enabled: true,
+ configs: {
+ detail: Resolution.low,
+ variable_selector: ['node', 'files'],
+ },
+ }),
+ onChange,
+ }))
+
+ act(() => {
+ result.current.handleVisionResolutionEnabledChange(false)
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ enabled: false,
+ })
+ })
+
+ it('should update the resolution config payload directly', () => {
+ const onChange = vi.fn()
+ const config: VisionSetting = {
+ detail: Resolution.low,
+ variable_selector: ['upstream', 'images'],
+ }
+
+ const { result } = renderHook(() => useConfigVision(createModel(), {
+ payload: createVisionPayload({ enabled: true }),
+ onChange,
+ }))
+
+ act(() => {
+ result.current.handleVisionResolutionChange(config)
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ enabled: true,
+ configs: config,
+ })
+ })
+
+ it('should disable vision settings when the selected model is no longer a vision model', () => {
+ const onChange = vi.fn()
+
+ const { result } = renderHook(() => useConfigVision(createModel(), {
+ payload: createVisionPayload({
+ enabled: true,
+ configs: {
+ detail: Resolution.high,
+ variable_selector: ['sys', 'files'],
+ },
+ }),
+ onChange,
+ }))
+
+ act(() => {
+ result.current.handleModelChanged()
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ enabled: false,
+ })
+ })
+
+ it('should reset enabled vision configs when the model changes but still supports vision', () => {
+ const onChange = vi.fn()
+ mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
+ currentModel: {
+ features: [ModelFeatureEnum.vision],
+ },
+ })
+
+ const { result } = renderHook(() => useConfigVision(createModel(), {
+ payload: createVisionPayload({
+ enabled: true,
+ configs: {
+ detail: Resolution.low,
+ variable_selector: ['old', 'files'],
+ },
+ }),
+ onChange,
+ }))
+
+ act(() => {
+ result.current.handleModelChanged()
+ })
+
+ expect(onChange).toHaveBeenCalledWith({
+ enabled: true,
+ configs: {
+ detail: Resolution.high,
+ variable_selector: [],
+ },
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx
new file mode 100644
index 0000000000..d66e3ebe4a
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx
@@ -0,0 +1,146 @@
+import { renderHook } from '@testing-library/react'
+import { BlockEnum } from '../../types'
+import { useDynamicTestRunOptions } from '../use-dynamic-test-run-options'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockUseNodes = vi.hoisted(() => vi.fn())
+const mockUseStore = vi.hoisted(() => vi.fn())
+const mockUseAllTriggerPlugins = vi.hoisted(() => vi.fn())
+const mockGetWorkflowEntryNode = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
+ __esModule: true,
+ default: () => mockUseNodes(),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: {
+ buildInTools: unknown[]
+ customTools: unknown[]
+ workflowTools: unknown[]
+ mcpTools: unknown[]
+ }) => unknown) => mockUseStore(selector),
+}))
+
+vi.mock('@/service/use-triggers', () => ({
+ useAllTriggerPlugins: () => mockUseAllTriggerPlugins(),
+}))
+
+vi.mock('@/app/components/workflow/utils/workflow-entry', () => ({
+ getWorkflowEntryNode: (...args: unknown[]) => mockGetWorkflowEntryNode(...args),
+}))
+
+describe('useDynamicTestRunOptions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ mockUseStore.mockImplementation((selector: (state: {
+ buildInTools: unknown[]
+ customTools: unknown[]
+ workflowTools: unknown[]
+ mcpTools: unknown[]
+ }) => unknown) => selector({
+ buildInTools: [],
+ customTools: [],
+ workflowTools: [],
+ mcpTools: [],
+ }))
+ mockUseAllTriggerPlugins.mockReturnValue({
+ data: [{
+ name: 'plugin-provider',
+ icon: '/plugin-icon.png',
+ }],
+ })
+ })
+
+ it('should build user input, trigger options, and a run-all option from workflow nodes', () => {
+ mockUseNodes.mockReturnValue([
+ {
+ id: 'start-1',
+ data: { type: BlockEnum.Start, title: 'User Input' },
+ },
+ {
+ id: 'schedule-1',
+ data: { type: BlockEnum.TriggerSchedule, title: 'Daily Schedule' },
+ },
+ {
+ id: 'webhook-1',
+ data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' },
+ },
+ {
+ id: 'plugin-1',
+ data: {
+ type: BlockEnum.TriggerPlugin,
+ title: '',
+ plugin_name: 'Plugin Trigger',
+ provider_id: 'plugin-provider',
+ },
+ },
+ ])
+
+ const { result } = renderHook(() => useDynamicTestRunOptions())
+
+ expect(result.current.userInput).toEqual(expect.objectContaining({
+ id: 'start-1',
+ type: 'user_input',
+ name: 'User Input',
+ nodeId: 'start-1',
+ enabled: true,
+ }))
+ expect(result.current.triggers).toEqual([
+ expect.objectContaining({
+ id: 'schedule-1',
+ type: 'schedule',
+ name: 'Daily Schedule',
+ nodeId: 'schedule-1',
+ }),
+ expect.objectContaining({
+ id: 'webhook-1',
+ type: 'webhook',
+ name: 'Webhook Trigger',
+ nodeId: 'webhook-1',
+ }),
+ expect.objectContaining({
+ id: 'plugin-1',
+ type: 'plugin',
+ name: 'Plugin Trigger',
+ nodeId: 'plugin-1',
+ }),
+ ])
+ expect(result.current.runAll).toEqual(expect.objectContaining({
+ id: 'run-all',
+ type: 'all',
+ relatedNodeIds: ['schedule-1', 'webhook-1', 'plugin-1'],
+ }))
+ })
+
+ it('should fall back to the workflow entry node and omit run-all when only one trigger exists', () => {
+ mockUseNodes.mockReturnValue([
+ {
+ id: 'webhook-1',
+ data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' },
+ },
+ ])
+ mockGetWorkflowEntryNode.mockReturnValue({
+ id: 'fallback-start',
+ data: { type: BlockEnum.Start, title: '' },
+ })
+
+ const { result } = renderHook(() => useDynamicTestRunOptions())
+
+ expect(result.current.userInput).toEqual(expect.objectContaining({
+ id: 'fallback-start',
+ type: 'user_input',
+ name: 'blocks.start',
+ nodeId: 'fallback-start',
+ }))
+ expect(result.current.triggers).toHaveLength(1)
+ expect(result.current.runAll).toBeUndefined()
+ })
+})
diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..91d346abc9
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx
@@ -0,0 +1,235 @@
+import { act, render, screen } from '@testing-library/react'
+import { NodeRunningStatus } from '@/app/components/workflow/types'
+import LastRun from '../index'
+
+const mockUseHooksStore = vi.hoisted(() => vi.fn())
+const mockUseLastRun = vi.hoisted(() => vi.fn())
+const mockResultPanel = vi.hoisted(() => vi.fn())
+
+vi.mock('@remixicon/react', () => ({
+ RiLoader2Line: () => ,
+}))
+
+vi.mock('@/app/components/workflow/hooks-store', () => ({
+ useHooksStore: (selector: (state: {
+ configsMap?: { flowType?: string, flowId?: string }
+ }) => unknown) => mockUseHooksStore(selector),
+}))
+
+vi.mock('@/service/use-workflow', () => ({
+ useLastRun: (...args: unknown[]) => mockUseLastRun(...args),
+}))
+
+vi.mock('@/app/components/workflow/run/result-panel', () => ({
+ __esModule: true,
+ default: (props: Record) => {
+ mockResultPanel(props)
+ return {String(props.status)}
+ },
+}))
+
+vi.mock('../no-data', () => ({
+ __esModule: true,
+ default: ({ onSingleRun }: { onSingleRun: () => void }) => (
+
+ ),
+}))
+
+describe('LastRun', () => {
+ const updateNodeRunningStatus = vi.fn()
+ const onSingleRunClicked = vi.fn()
+ let visibilityState = 'visible'
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseHooksStore.mockImplementation((selector: (state: {
+ configsMap?: { flowType?: string, flowId?: string }
+ }) => unknown) => selector({
+ configsMap: {
+ flowType: 'appFlow',
+ flowId: 'flow-1',
+ },
+ }))
+ mockUseLastRun.mockReturnValue({
+ data: undefined,
+ isFetching: false,
+ error: undefined,
+ })
+ visibilityState = 'visible'
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => visibilityState,
+ })
+ })
+
+ it('should show a loader while fetching the last run before any single run starts', () => {
+ mockUseLastRun.mockReturnValue({
+ data: undefined,
+ isFetching: true,
+ error: undefined,
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('loading-icon')).toBeInTheDocument()
+ expect(screen.queryByTestId('result-panel')).not.toBeInTheDocument()
+ })
+
+ it('should show a running result panel while a single run is still executing', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('result-panel')).toHaveTextContent('running')
+ expect(mockResultPanel).toHaveBeenCalledWith(expect.objectContaining({
+ status: 'running',
+ showSteps: false,
+ }))
+ })
+
+ it('should render the no-data state for 404 last-run responses and forward single-run clicks', () => {
+ mockUseLastRun.mockReturnValue({
+ data: undefined,
+ isFetching: false,
+ error: { status: 404 },
+ })
+
+ render(
+ ,
+ )
+
+ act(() => {
+ screen.getByText('no-data').click()
+ })
+
+ expect(onSingleRunClicked).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render resolved result data and let paused state override the final status', () => {
+ mockUseLastRun.mockReturnValue({
+ data: {
+ status: NodeRunningStatus.Succeeded,
+ execution_metadata: { total_tokens: 9 },
+ created_by_account: { created_by: 'Alice' },
+ },
+ isFetching: false,
+ error: undefined,
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Stopped)
+ expect(mockResultPanel).toHaveBeenCalledWith(expect.objectContaining({
+ status: NodeRunningStatus.Stopped,
+ total_tokens: 9,
+ created_by: 'Alice',
+ showSteps: false,
+ }))
+ })
+
+ it('should respect stopped and listening one-step statuses', () => {
+ mockUseLastRun.mockReturnValue({
+ data: {
+ status: NodeRunningStatus.Succeeded,
+ },
+ isFetching: false,
+ error: undefined,
+ })
+
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Stopped)
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Listening)
+ })
+
+ it('should react to page visibility changes while keeping the current result rendered', () => {
+ mockUseLastRun.mockReturnValue({
+ data: {
+ status: NodeRunningStatus.Succeeded,
+ },
+ isFetching: false,
+ error: undefined,
+ })
+
+ render(
+ ,
+ )
+
+ act(() => {
+ visibilityState = 'hidden'
+ document.dispatchEvent(new Event('visibilitychange'))
+ visibilityState = 'visible'
+ document.dispatchEvent(new Event('visibilitychange'))
+ })
+
+ expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Succeeded)
+ })
+})
diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..6d009ba60b
--- /dev/null
+++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts
@@ -0,0 +1,139 @@
+import type { DataSourceNodeType } from '../../types'
+import { renderHook } from '@testing-library/react'
+import { VarType as VarKindType } from '../../types'
+import { useConfig } from '../use-config'
+
+const mockUseStoreApi = vi.hoisted(() => vi.fn())
+const mockUseNodeDataUpdate = vi.hoisted(() => vi.fn())
+
+vi.mock('reactflow', () => ({
+ useStoreApi: () => mockUseStoreApi(),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodeDataUpdate: () => mockUseNodeDataUpdate(),
+}))
+
+const createNode = (overrides: Partial = {}): { id: string, data: DataSourceNodeType } => ({
+ id: 'data-source-node',
+ data: {
+ title: 'Datasource',
+ desc: '',
+ type: 'data-source',
+ plugin_id: 'plugin-1',
+ provider_type: 'local_file',
+ provider_name: 'provider',
+ datasource_name: 'source-a',
+ datasource_label: 'Source A',
+ datasource_parameters: {},
+ datasource_configurations: {},
+ _dataSourceStartToAdd: true,
+ ...overrides,
+ } as DataSourceNodeType,
+})
+
+describe('data-source/hooks/use-config', () => {
+ const mockHandleNodeDataUpdateWithSyncDraft = vi.fn()
+ let currentNode = createNode()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentNode = createNode()
+
+ mockUseStoreApi.mockReturnValue({
+ getState: () => ({
+ getNodes: () => [currentNode],
+ }),
+ })
+ mockUseNodeDataUpdate.mockReturnValue({
+ handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
+ })
+ })
+
+ it('should clear the local-file auto-add flag on mount and update datasource payloads', () => {
+ const { result } = renderHook(() => useConfig('data-source-node'))
+
+ expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
+ id: 'data-source-node',
+ data: expect.objectContaining({
+ _dataSourceStartToAdd: false,
+ }),
+ })
+
+ mockHandleNodeDataUpdateWithSyncDraft.mockClear()
+ result.current.handleFileExtensionsChange(['pdf', 'csv'])
+ result.current.handleParametersChange({
+ dataset: {
+ type: VarKindType.constant,
+ value: 'docs',
+ },
+ })
+
+ expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(1, {
+ id: 'data-source-node',
+ data: expect.objectContaining({
+ fileExtensions: ['pdf', 'csv'],
+ }),
+ })
+ expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(2, {
+ id: 'data-source-node',
+ data: expect.objectContaining({
+ datasource_parameters: {
+ dataset: {
+ type: VarKindType.constant,
+ value: 'docs',
+ },
+ },
+ }),
+ })
+ })
+
+ it('should derive output schema metadata and detect object outputs', () => {
+ const dataSourceList = [{
+ plugin_id: 'plugin-1',
+ tools: [{
+ name: 'source-a',
+ output_schema: {
+ properties: {
+ items: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'List of items',
+ },
+ metadata: {
+ type: 'object',
+ description: 'Object field',
+ },
+ count: {
+ type: 'number',
+ description: 'Total count',
+ },
+ },
+ },
+ }],
+ }]
+
+ const { result } = renderHook(() => useConfig('data-source-node', dataSourceList))
+
+ expect(result.current.outputSchema).toEqual([
+ {
+ name: 'items',
+ type: 'Array[String]',
+ description: 'List of items',
+ },
+ {
+ name: 'metadata',
+ value: {
+ type: 'object',
+ description: 'Object field',
+ },
+ },
+ {
+ name: 'count',
+ type: 'Number',
+ description: 'Total count',
+ },
+ ])
+ expect(result.current.hasObjectOutput).toBe(true)
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx
new file mode 100644
index 0000000000..b7b0229424
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx
@@ -0,0 +1,149 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import * as React from 'react'
+import { UserActionButtonType } from '../../types'
+import ButtonStyleDropdown from '../button-style-dropdown'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockButton = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/base/button', () => ({
+ __esModule: true,
+ default: (props: {
+ variant?: string
+ children?: React.ReactNode
+ className?: string
+ }) => {
+ mockButton(props)
+ return {props.children}
+ },
+}))
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => {
+ const OpenContext = React.createContext(false)
+
+ return {
+ PortalToFollowElem: ({
+ open,
+ children,
+ }: {
+ open: boolean
+ children?: React.ReactNode
+ }) => (
+
+ {children}
+
+ ),
+ PortalToFollowElemTrigger: ({
+ children,
+ onClick,
+ }: {
+ children?: React.ReactNode
+ onClick?: () => void
+ }) => (
+
+ ),
+ PortalToFollowElemContent: ({
+ children,
+ }: {
+ children?: React.ReactNode
+ }) => {
+ const open = React.use(OpenContext)
+ return open ? {children}
: null
+ },
+ }
+})
+
+describe('ButtonStyleDropdown', () => {
+ const onChange = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ })
+
+ it('should map the current style to the trigger button and update the selected style', () => {
+ render(
+ ,
+ )
+
+ expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
+ variant: 'ghost',
+ }))
+ expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
+
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+ expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
+ expect(screen.getByText('nodes.humanInput.userActions.chooseStyle')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('button-primary').parentElement as HTMLElement)
+ fireEvent.click(screen.getByTestId('button-secondary').parentElement as HTMLElement)
+ fireEvent.click(screen.getByTestId('button-secondary-accent').parentElement as HTMLElement)
+ fireEvent.click(screen.getAllByTestId('button-ghost')[1].parentElement as HTMLElement)
+
+ expect(onChange).toHaveBeenNthCalledWith(1, UserActionButtonType.Primary)
+ expect(onChange).toHaveBeenNthCalledWith(2, UserActionButtonType.Default)
+ expect(onChange).toHaveBeenNthCalledWith(3, UserActionButtonType.Accent)
+ expect(onChange).toHaveBeenNthCalledWith(4, UserActionButtonType.Ghost)
+ })
+
+ it('should keep the dropdown closed in readonly mode', () => {
+ render(
+ ,
+ )
+
+ expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
+ variant: 'secondary',
+ }))
+
+ fireEvent.click(screen.getByTestId('portal-trigger'))
+
+ expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('should map the accent style to the secondary-accent trigger button', () => {
+ render(
+ ,
+ )
+
+ expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
+ variant: 'secondary-accent',
+ }))
+ })
+
+ it('should map the primary style to the primary trigger button', () => {
+ render(
+ ,
+ )
+
+ expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
+ variant: 'primary',
+ }))
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx
new file mode 100644
index 0000000000..e98a74e6b4
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx
@@ -0,0 +1,135 @@
+import type { ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { UserActionButtonType } from '../../types'
+import FormContentPreview from '../form-content-preview'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockUseStore = vi.hoisted(() => vi.fn())
+const mockUseNodes = vi.hoisted(() => vi.fn())
+const mockGetButtonStyle = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { panelWidth: number }) => unknown) => mockUseStore(selector),
+}))
+
+vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
+ __esModule: true,
+ default: () => mockUseNodes(),
+}))
+
+vi.mock('@/app/components/base/action-button', () => ({
+ __esModule: true,
+ default: ({ children, onClick }: { children?: ReactNode, onClick?: () => void }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/badge', () => ({
+ __esModule: true,
+ default: ({ children }: { children?: ReactNode }) => {children}
,
+}))
+
+vi.mock('@/app/components/base/button', () => ({
+ __esModule: true,
+ default: ({ children, variant }: { children?: ReactNode, variant?: string }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/chat/chat/answer/human-input-content/utils', () => ({
+ getButtonStyle: (...args: unknown[]) => mockGetButtonStyle(...args),
+}))
+
+vi.mock('@/app/components/base/markdown', () => ({
+ Markdown: ({ customComponents }: {
+ customComponents: {
+ variable: (props: { node: { properties: { dataPath: string } } }) => ReactNode
+ section: (props: { node: { properties: { dataName: string } } }) => ReactNode
+ }
+ }) => (
+
+ {customComponents.variable({ node: { properties: { dataPath: '#node-1.answer#' } } })}
+ {customComponents.section({ node: { properties: { dataName: 'field_1' } } })}
+ {customComponents.section({ node: { properties: { dataName: 'missing_field' } } })}
+
+ ),
+}))
+
+vi.mock('../variable-in-markdown', () => ({
+ rehypeNotes: vi.fn(),
+ rehypeVariable: vi.fn(),
+ Variable: ({ path }: { path: string }) => {path}
,
+ Note: ({ defaultInput, nodeName }: {
+ defaultInput: { selector: string[] }
+ nodeName: (nodeId: string) => string
+ }) => {nodeName(defaultInput.selector[0])}
,
+}))
+
+describe('FormContentPreview', () => {
+ const onClose = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ mockUseStore.mockImplementation((selector: (state: { panelWidth: number }) => unknown) => selector({ panelWidth: 320 }))
+ mockUseNodes.mockReturnValue([{
+ id: 'node-1',
+ data: { title: 'Classifier' },
+ }])
+ mockGetButtonStyle.mockImplementation((style: UserActionButtonType) => style.toLowerCase())
+ })
+
+ it('should render preview content with resolved node names, note fallbacks, and action buttons', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(container.firstChild).toHaveStyle({ right: '328px' })
+ expect(screen.getByTestId('badge')).toHaveTextContent('nodes.humanInput.formContent.preview')
+ expect(screen.getByTestId('variable-path')).toHaveTextContent('#Classifier.answer#')
+ expect(screen.getByTestId('note')).toHaveTextContent('Classifier')
+ expect(screen.getByText(/Can't find note:/)).toHaveTextContent('missing_field')
+ expect(screen.getByTestId('action-primary')).toHaveTextContent('Approve')
+ expect(screen.getByText('nodes.humanInput.editor.previewTip')).toBeInTheDocument()
+ })
+
+ it('should close the preview when the close action is clicked', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'close-preview' }))
+
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx
new file mode 100644
index 0000000000..218da57fbb
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx
@@ -0,0 +1,258 @@
+import type { ReactNode } from 'react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import FormContent from '../form-content'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockUseWorkflowVariableType = vi.hoisted(() => vi.fn())
+const mockIsMac = vi.hoisted(() => vi.fn())
+const mockPromptEditor = vi.hoisted(() => vi.fn())
+const mockAddInputField = vi.hoisted(() => vi.fn())
+const mockOnInsert = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+ Trans: ({
+ i18nKey,
+ components,
+ }: {
+ i18nKey: string
+ components?: Record
+ }) => (
+
+
{i18nKey}
+ {components?.CtrlKey}
+ {components?.Key}
+
+ ),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useWorkflowVariableType: () => mockUseWorkflowVariableType(),
+}))
+
+vi.mock('@/app/components/workflow/utils', () => ({
+ isMac: () => mockIsMac(),
+}))
+
+vi.mock('@/app/components/base/prompt-editor', () => ({
+ __esModule: true,
+ default: (props: {
+ onChange: (value: string) => void
+ onFocus: () => void
+ onBlur: () => void
+ shortcutPopups?: Array<{
+ Popup: (props: { onClose: () => void, onInsert: typeof mockOnInsert }) => ReactNode
+ }>
+ editable?: boolean
+ hitlInputBlock: {
+ workflowNodesMap: Record
+ }
+ }) => {
+ mockPromptEditor(props)
+ const popup = props.shortcutPopups?.[0]
+ return (
+
+
+
+
+ {popup && popup.Popup({ onClose: vi.fn(), onInsert: mockOnInsert })}
+
+ )
+ },
+}))
+
+vi.mock('../add-input-field', () => ({
+ __esModule: true,
+ default: (props: {
+ onSave: (payload: {
+ type: string
+ output_variable_name: string
+ default: {
+ type: string
+ selector: string[]
+ value: string
+ }
+ }) => void
+ onCancel: () => void
+ }) => {
+ mockAddInputField(props)
+ return (
+
+
+
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/base/prompt-editor/plugins/hitl-input-block', () => ({
+ INSERT_HITL_INPUT_BLOCK_COMMAND: 'INSERT_HITL_INPUT_BLOCK_COMMAND',
+}))
+
+describe('FormContent', () => {
+ const onChange = vi.fn()
+ const onFormInputsChange = vi.fn()
+ const onFormInputItemRename = vi.fn()
+ const onFormInputItemRemove = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ mockUseWorkflowVariableType.mockReturnValue(() => 'string')
+ mockIsMac.mockReturnValue(false)
+ })
+
+ it('should build workflow node maps, show the hotkey tip on focus, and defer form-input sync until value changes', async () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
+ editable: true,
+ hitlInputBlock: expect.objectContaining({
+ workflowNodesMap: expect.objectContaining({
+ 'node-1': expect.objectContaining({ title: 'Start' }),
+ 'node-2': expect.objectContaining({ title: 'Classifier' }),
+ 'sys': expect.objectContaining({ title: 'blocks.start' }),
+ }),
+ }),
+ }))
+
+ fireEvent.click(screen.getByText('focus-editor'))
+ expect(screen.getByText('nodes.humanInput.formContent.hotkeyTip')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('save-input'))
+ expect(mockOnInsert).toHaveBeenCalledWith('INSERT_HITL_INPUT_BLOCK_COMMAND', expect.objectContaining({
+ variableName: 'approval',
+ nodeId: 'node-2',
+ formInputs: [expect.objectContaining({ output_variable_name: 'approval' })],
+ onFormInputsChange,
+ onFormInputItemRename,
+ onFormInputItemRemove,
+ }))
+ expect(onFormInputsChange).not.toHaveBeenCalled()
+
+ rerender(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(onFormInputsChange).toHaveBeenCalledWith([
+ expect.objectContaining({ output_variable_name: 'approval' }),
+ ])
+ })
+ })
+
+ it('should disable editing helpers in readonly mode', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
+ editable: false,
+ shortcutPopups: [],
+ }))
+ expect(screen.queryByText('save-input')).not.toBeInTheDocument()
+ expect(container.firstChild).toHaveClass('pointer-events-none')
+ })
+
+ it('should render the mac hotkey hint when focused on macOS', () => {
+ mockIsMac.mockReturnValue(true)
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('focus-editor'))
+
+ expect(screen.getByText('⌘')).toBeInTheDocument()
+ expect(screen.getByText('/')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx
new file mode 100644
index 0000000000..0424fac72d
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx
@@ -0,0 +1,77 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import TimeoutInput from '../timeout'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/base/input', () => ({
+ __esModule: true,
+ default: (props: {
+ value: number
+ disabled?: boolean
+ onChange: (event: { target: { value: string } }) => void
+ }) => (
+ props.onChange({ target: { value: e.target.value } })}
+ />
+ ),
+}))
+
+describe('TimeoutInput', () => {
+ const onChange = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ })
+
+ it('should update the numeric timeout value and switch units', () => {
+ render(
+ ,
+ )
+
+ fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: '12' } })
+ fireEvent.click(screen.getByText('nodes.humanInput.timeout.hours'))
+
+ expect(onChange).toHaveBeenNthCalledWith(1, { timeout: 12, unit: 'day' })
+ expect(onChange).toHaveBeenNthCalledWith(2, { timeout: 3, unit: 'hour' })
+ })
+
+ it('should fall back to 1 on invalid input and stay read-only when disabled', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: 'abc' } })
+ expect(onChange).toHaveBeenCalledWith({ timeout: 1, unit: 'hour' })
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('nodes.humanInput.timeout.days'))
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(screen.getByTestId('timeout-input')).toBeDisabled()
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx
new file mode 100644
index 0000000000..a4111c88ca
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx
@@ -0,0 +1,143 @@
+import type { ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { UserActionButtonType } from '../../types'
+import UserActionItem from '../user-action'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockNotify = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/base/input', () => ({
+ __esModule: true,
+ default: (props: {
+ value: string
+ placeholder?: string
+ disabled?: boolean
+ onChange: (event: { target: { value: string } }) => void
+ }) => (
+ props.onChange({ target: { value: e.target.value } })}
+ />
+ ),
+}))
+
+vi.mock('@/app/components/base/button', () => ({
+ __esModule: true,
+ default: (props: {
+ children?: ReactNode
+ onClick?: () => void
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/toast', () => ({
+ __esModule: true,
+ default: {
+ notify: (...args: unknown[]) => mockNotify(...args),
+ },
+}))
+
+vi.mock('../button-style-dropdown', () => ({
+ __esModule: true,
+ default: (props: {
+ onChange: (type: UserActionButtonType) => void
+ }) => (
+
+ ),
+}))
+
+describe('UserActionItem', () => {
+ const onChange = vi.fn()
+ const onDelete = vi.fn()
+ const action = {
+ id: 'approve',
+ title: 'Approve',
+ button_style: UserActionButtonType.Primary,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ })
+
+ it('should sanitize ids, enforce length limits, and update the button text', () => {
+ render(
+ ,
+ )
+
+ fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'Approve action' } })
+ fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: '1invalid' } })
+ fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'averyveryveryverylongidentifier' } })
+ fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder'), { target: { value: 'A very very very long button title' } })
+
+ expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ id: 'Approve_action',
+ }))
+ expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ id: 'averyveryveryverylon',
+ }))
+ expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({
+ title: 'A very very very lon',
+ }))
+ expect(mockNotify).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ type: 'error',
+ message: 'nodes.humanInput.userActions.actionIdFormatTip',
+ }))
+ expect(mockNotify).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ type: 'error',
+ message: 'nodes.humanInput.userActions.actionIdTooLong',
+ }))
+ expect(mockNotify).toHaveBeenNthCalledWith(3, expect.objectContaining({
+ type: 'error',
+ message: 'nodes.humanInput.userActions.buttonTextTooLong',
+ }))
+ })
+
+ it('should support clearing ids, updating button style, deleting, and readonly mode', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: ' ' } })
+ fireEvent.click(screen.getByText('change-style'))
+ fireEvent.click(screen.getAllByRole('button')[1])
+
+ expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: '' }))
+ expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ button_style: UserActionButtonType.Ghost }))
+ expect(onDelete).toHaveBeenCalledWith('approve')
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder')).toBeDisabled()
+ expect(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder')).toBeDisabled()
+ expect(screen.getAllByRole('button')).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..03bc0f2b79
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx
@@ -0,0 +1,150 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { DeliveryMethodType } from '../../../types'
+import DeliveryMethodForm from '../index'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockUseNodesSyncDraft = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/base/tooltip', () => ({
+ __esModule: true,
+ default: ({ popupContent }: { popupContent: string }) => {popupContent}
,
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesSyncDraft: () => mockUseNodesSyncDraft(),
+}))
+
+vi.mock('../method-selector', () => ({
+ __esModule: true,
+ default: (props: {
+ onAdd: (method: { id: string, type: DeliveryMethodType, enabled: boolean }) => void
+ onShowUpgradeTip: () => void
+ }) => (
+
+
+
+
+ ),
+}))
+
+vi.mock('../method-item', () => ({
+ __esModule: true,
+ default: (props: {
+ method: { type: DeliveryMethodType, enabled: boolean }
+ onChange: (method: { type: DeliveryMethodType, enabled: boolean }) => void
+ onDelete: (type: DeliveryMethodType) => void
+ }) => (
+
+
+
+
+ ),
+}))
+
+vi.mock('../upgrade-modal', () => ({
+ __esModule: true,
+ default: ({ onClose }: { onClose: () => void }) => (
+
+ ),
+}))
+
+describe('DeliveryMethodForm', () => {
+ const onChange = vi.fn()
+ const mockHandleSyncWorkflowDraft = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ mockUseNodesSyncDraft.mockReturnValue({
+ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
+ })
+ })
+
+ it('should render the empty state and add methods through the selector', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('nodes.humanInput.deliveryMethod.emptyTip')).toBeInTheDocument()
+ fireEvent.click(screen.getByText('add-method'))
+
+ expect(onChange).toHaveBeenCalledWith([
+ {
+ id: 'email-1',
+ type: DeliveryMethodType.Email,
+ enabled: false,
+ },
+ ])
+ expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
+ })
+
+ it('should change and delete methods, syncing the draft after updates', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('change-method'))
+ fireEvent.click(screen.getByText('delete-method'))
+
+ expect(onChange).toHaveBeenNthCalledWith(1, [{
+ id: 'email-1',
+ type: DeliveryMethodType.Email,
+ enabled: true,
+ }])
+ expect(onChange).toHaveBeenNthCalledWith(2, [])
+ expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true)
+ })
+
+ it('should open and close the upgrade modal', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('show-upgrade'))
+ expect(screen.getByText('upgrade-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('upgrade-modal'))
+ expect(screen.queryByText('upgrade-modal')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..96cfc10c23
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx
@@ -0,0 +1,156 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import Recipient from '../index'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockUseAppContext = vi.hoisted(() => vi.fn())
+const mockUseMembers = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => mockUseAppContext(),
+}))
+
+vi.mock('@/service/use-common', () => ({
+ useMembers: () => mockUseMembers(),
+}))
+
+vi.mock('@/app/components/base/switch', () => ({
+ __esModule: true,
+ default: (props: {
+ value: boolean
+ onChange: (value: boolean) => void
+ }) => (
+
+ ),
+}))
+
+vi.mock('../member-selector', () => ({
+ __esModule: true,
+ default: ({ onSelect }: { onSelect: (id: string) => void }) => (
+
+ ),
+}))
+
+vi.mock('../email-input', () => ({
+ __esModule: true,
+ default: (props: {
+ onAdd: (email: string) => void
+ onSelect: (id: string) => void
+ onDelete: (recipient: { type: 'member' | 'external', user_id?: string, email?: string }) => void
+ }) => (
+
+
+
+
+
+
+ ),
+}))
+
+describe('Recipient', () => {
+ const onChange = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string, options?: { workspaceName?: string }) => options?.workspaceName ?? key,
+ })
+ mockUseAppContext.mockReturnValue({
+ userProfile: { email: 'owner@example.com' },
+ currentWorkspace: { name: 'Dify\'s Lab' },
+ })
+ mockUseMembers.mockReturnValue({
+ data: {
+ accounts: [
+ { id: 'member-1', email: 'member-1@example.com', name: 'Member One' },
+ { id: 'member-2', email: 'member-2@example.com', name: 'Member Two' },
+ { id: 'member-3', email: 'member-3@example.com', name: 'Member Three' },
+ ],
+ },
+ })
+ })
+
+ it('should render workspace details and update recipients through member/email actions', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('D')).toBeInTheDocument()
+ expect(screen.getByText('Dify’s Lab')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('add-member'))
+ fireEvent.click(screen.getByText('add-email'))
+ fireEvent.click(screen.getByText('add-email-member'))
+ fireEvent.click(screen.getByText('delete-member'))
+ fireEvent.click(screen.getByText('delete-external'))
+ fireEvent.click(screen.getByText('toggle-workspace'))
+
+ expect(onChange).toHaveBeenNthCalledWith(1, {
+ whole_workspace: false,
+ items: [
+ { type: 'member', user_id: 'member-1' },
+ { type: 'external', email: 'external@example.com' },
+ { type: 'member', user_id: 'member-2' },
+ ],
+ })
+ expect(onChange).toHaveBeenNthCalledWith(2, {
+ whole_workspace: false,
+ items: [
+ { type: 'member', user_id: 'member-1' },
+ { type: 'external', email: 'external@example.com' },
+ { type: 'external', email: 'new@example.com' },
+ ],
+ })
+ expect(onChange).toHaveBeenNthCalledWith(3, {
+ whole_workspace: false,
+ items: [
+ { type: 'member', user_id: 'member-1' },
+ { type: 'external', email: 'external@example.com' },
+ { type: 'member', user_id: 'member-3' },
+ ],
+ })
+ expect(onChange).toHaveBeenNthCalledWith(4, {
+ whole_workspace: false,
+ items: [
+ { type: 'external', email: 'external@example.com' },
+ ],
+ })
+ expect(onChange).toHaveBeenNthCalledWith(5, {
+ whole_workspace: false,
+ items: [
+ { type: 'member', user_id: 'member-1' },
+ ],
+ })
+ expect(onChange).toHaveBeenNthCalledWith(6, {
+ whole_workspace: true,
+ items: [
+ { type: 'member', user_id: 'member-1' },
+ { type: 'external', email: 'external@example.com' },
+ ],
+ })
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..ce9bdfc295
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts
@@ -0,0 +1,156 @@
+import type { DeliveryMethod, HumanInputNodeType, UserAction } from '../../types'
+import { act, renderHook } from '@testing-library/react'
+import { BlockEnum } from '@/app/components/workflow/types'
+import useConfig from '../use-config'
+
+const mockUseUpdateNodeInternals = vi.hoisted(() => vi.fn())
+const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
+const mockUseEdgesInteractions = vi.hoisted(() => vi.fn())
+const mockUseNodeCrud = vi.hoisted(() => vi.fn())
+const mockUseFormContent = vi.hoisted(() => vi.fn())
+
+vi.mock('reactflow', () => ({
+ useUpdateNodeInternals: () => mockUseUpdateNodeInternals(),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesReadOnly: () => mockUseNodesReadOnly(),
+}))
+
+vi.mock('@/app/components/workflow/hooks/use-edges-interactions', () => ({
+ useEdgesInteractions: () => mockUseEdgesInteractions(),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseNodeCrud(...args),
+}))
+
+vi.mock('../use-form-content', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseFormContent(...args),
+}))
+
+const createPayload = (overrides: Partial = {}): HumanInputNodeType => ({
+ title: 'Human Input',
+ desc: '',
+ type: BlockEnum.HumanInput,
+ delivery_methods: [{
+ id: 'webapp',
+ type: 'webapp',
+ enabled: true,
+ } as DeliveryMethod],
+ form_content: 'Body',
+ inputs: [],
+ user_actions: [{
+ id: 'approve',
+ title: 'Approve',
+ button_style: 'primary',
+ } as UserAction],
+ timeout: 3,
+ timeout_unit: 'day',
+ ...overrides,
+})
+
+describe('human-input/hooks/use-config', () => {
+ const mockSetInputs = vi.fn()
+ const mockHandleEdgeDeleteByDeleteBranch = vi.fn()
+ const mockHandleEdgeSourceHandleChange = vi.fn()
+ const mockUpdateNodeInternals = vi.fn()
+ const formContentHook = {
+ editorKey: 3,
+ handleFormContentChange: vi.fn(),
+ handleFormInputsChange: vi.fn(),
+ handleFormInputItemRename: vi.fn(),
+ handleFormInputItemRemove: vi.fn(),
+ }
+ let currentInputs = createPayload()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentInputs = createPayload()
+ mockUseUpdateNodeInternals.mockReturnValue(mockUpdateNodeInternals)
+ mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
+ mockUseEdgesInteractions.mockReturnValue({
+ handleEdgeDeleteByDeleteBranch: mockHandleEdgeDeleteByDeleteBranch,
+ handleEdgeSourceHandleChange: mockHandleEdgeSourceHandleChange,
+ })
+ mockUseNodeCrud.mockImplementation(() => ({
+ inputs: currentInputs,
+ setInputs: mockSetInputs,
+ }))
+ mockUseFormContent.mockReturnValue(formContentHook)
+ })
+
+ it('should expose form-content helpers and update delivery methods, timeout, and collapsed state', () => {
+ const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
+ const methods = [{
+ id: 'email',
+ type: 'email',
+ enabled: true,
+ } as DeliveryMethod]
+
+ expect(result.current.editorKey).toBe(3)
+ expect(result.current.readOnly).toBe(false)
+ expect(result.current.structuredOutputCollapsed).toBe(true)
+
+ act(() => {
+ result.current.handleDeliveryMethodChange(methods)
+ result.current.handleTimeoutChange({ timeout: 12, unit: 'hour' })
+ result.current.setStructuredOutputCollapsed(false)
+ })
+
+ expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ delivery_methods: methods,
+ }))
+ expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ timeout: 12,
+ timeout_unit: 'hour',
+ }))
+ expect(result.current.structuredOutputCollapsed).toBe(false)
+ })
+
+ it('should append and delete user actions while syncing branch-edge cleanup', () => {
+ const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
+ const newAction = {
+ id: 'reject',
+ title: 'Reject',
+ button_style: 'default',
+ } as UserAction
+
+ act(() => {
+ result.current.handleUserActionAdd(newAction)
+ result.current.handleUserActionDelete('approve')
+ })
+
+ expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ user_actions: [
+ expect.objectContaining({ id: 'approve' }),
+ newAction,
+ ],
+ }))
+ expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ user_actions: [],
+ }))
+ expect(mockHandleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('human-input-node', 'approve')
+ })
+
+ it('should update user action ids and refresh source handles when the branch key changes', () => {
+ const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
+ const renamedAction = {
+ id: 'approved',
+ title: 'Approve',
+ button_style: 'primary',
+ } as UserAction
+
+ act(() => {
+ result.current.handleUserActionChange(0, renamedAction)
+ })
+
+ expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
+ user_actions: [renamedAction],
+ }))
+ expect(mockHandleEdgeSourceHandleChange).toHaveBeenCalledWith('human-input-node', 'approve', 'approved')
+ expect(mockUpdateNodeInternals).toHaveBeenCalledWith('human-input-node')
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts
new file mode 100644
index 0000000000..c809e51595
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts
@@ -0,0 +1,112 @@
+import type { FormInputItem, HumanInputNodeType } from '../../types'
+import { act, renderHook } from '@testing-library/react'
+import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
+import useFormContent from '../use-form-content'
+
+const mockUseWorkflow = vi.hoisted(() => vi.fn())
+const mockUseNodeCrud = vi.hoisted(() => vi.fn())
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useWorkflow: () => mockUseWorkflow(),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseNodeCrud(...args),
+}))
+
+const createFormInput = (overrides: Partial = {}): FormInputItem => ({
+ type: InputVarType.textInput,
+ output_variable_name: 'old_name',
+ default: {
+ selector: [],
+ type: 'constant',
+ value: '',
+ },
+ ...overrides,
+})
+
+const createPayload = (overrides: Partial = {}): HumanInputNodeType => ({
+ title: 'Human Input',
+ desc: '',
+ type: BlockEnum.HumanInput,
+ delivery_methods: [],
+ form_content: 'Hello {{#$output.old_name#}}',
+ inputs: [createFormInput()],
+ user_actions: [],
+ timeout: 1,
+ timeout_unit: 'day',
+ ...overrides,
+})
+
+describe('human-input/use-form-content', () => {
+ const mockSetInputs = vi.fn()
+ const mockHandleOutVarRenameChange = vi.fn()
+ let currentInputs = createPayload()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentInputs = createPayload()
+ mockUseWorkflow.mockReturnValue({
+ handleOutVarRenameChange: mockHandleOutVarRenameChange,
+ })
+ mockUseNodeCrud.mockImplementation(() => ({
+ inputs: currentInputs,
+ setInputs: mockSetInputs,
+ }))
+ })
+
+ it('should update raw form content and replace the form input list', () => {
+ const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
+ const nextInputs = [
+ createFormInput({
+ output_variable_name: 'approval',
+ }),
+ ]
+
+ act(() => {
+ result.current.handleFormContentChange('Updated body')
+ result.current.handleFormInputsChange(nextInputs)
+ })
+
+ expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ form_content: 'Updated body',
+ }))
+ expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ inputs: nextInputs,
+ }))
+ expect(result.current.editorKey).toBe(1)
+ })
+
+ it('should rename input placeholders inside markdown and notify downstream references', () => {
+ const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
+ const renamedInput = createFormInput({
+ output_variable_name: 'new_name',
+ })
+
+ act(() => {
+ result.current.handleFormInputItemRename(renamedInput, 'old_name')
+ })
+
+ expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
+ form_content: 'Hello {{#$output.new_name#}}',
+ inputs: [renamedInput],
+ }))
+ expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith('human-input-node', ['human-input-node', 'old_name'], ['human-input-node', 'new_name'])
+ expect(result.current.editorKey).toBe(1)
+ })
+
+ it('should remove an input placeholder and its form input metadata', () => {
+ const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
+
+ act(() => {
+ result.current.handleFormInputItemRemove('old_name')
+ })
+
+ expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
+ form_content: 'Hello ',
+ inputs: [],
+ }))
+ expect(result.current.editorKey).toBe(1)
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts
new file mode 100644
index 0000000000..571708e87d
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts
@@ -0,0 +1,234 @@
+import type { HumanInputNodeType } from '../../types'
+import type { InputVar } from '@/app/components/workflow/types'
+import type { HumanInputFormData } from '@/types/workflow'
+import { act, renderHook } from '@testing-library/react'
+import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
+import { AppModeEnum } from '@/types/app'
+import useSingleRunFormParams from '../use-single-run-form-params'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockUseAppStore = vi.hoisted(() => vi.fn())
+const mockFetchHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
+const mockSubmitHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
+const mockUseNodeCrud = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/app/store', () => ({
+ useStore: (selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => mockUseAppStore(selector),
+}))
+
+vi.mock('@/service/workflow', () => ({
+ fetchHumanInputNodeStepRunForm: (...args: unknown[]) => mockFetchHumanInputNodeStepRunForm(...args),
+ submitHumanInputNodeStepRunForm: (...args: unknown[]) => mockSubmitHumanInputNodeStepRunForm(...args),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseNodeCrud(...args),
+}))
+
+const createPayload = (overrides: Partial = {}): HumanInputNodeType => ({
+ title: 'Human Input',
+ desc: '',
+ type: BlockEnum.HumanInput,
+ delivery_methods: [],
+ form_content: 'Summary: {{#start.topic#}}',
+ inputs: [{
+ type: InputVarType.textInput,
+ output_variable_name: 'summary',
+ default: {
+ type: 'variable',
+ selector: ['start', 'topic'],
+ value: '',
+ },
+ }],
+ user_actions: [],
+ timeout: 1,
+ timeout_unit: 'day',
+ ...overrides,
+})
+
+const createInputVar = (overrides: Partial = {}): InputVar => ({
+ type: InputVarType.textInput,
+ label: 'Topic',
+ variable: '#start.topic#',
+ required: false,
+ value_selector: ['start', 'topic'],
+ ...overrides,
+})
+
+const mockFormData: HumanInputFormData = {
+ form_id: 'form-1',
+ node_id: 'node-1',
+ node_title: 'Human Input',
+ form_content: 'Rendered content',
+ inputs: [],
+ actions: [],
+ form_token: 'token-1',
+ resolved_default_values: {
+ topic: 'AI',
+ },
+ display_in_ui: true,
+ expiration_time: 1000,
+}
+
+describe('human-input/hooks/use-single-run-form-params', () => {
+ const mockSetRunInputData = vi.fn()
+ const getInputVars = vi.fn()
+ let currentInputs = createPayload()
+ let appDetail: { id?: string, mode?: AppModeEnum } | undefined
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentInputs = createPayload()
+ appDetail = {
+ id: 'app-1',
+ mode: AppModeEnum.WORKFLOW,
+ }
+
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ mockUseAppStore.mockImplementation((selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => selector({ appDetail }))
+ mockUseNodeCrud.mockImplementation(() => ({
+ inputs: currentInputs,
+ }))
+ getInputVars.mockReturnValue([
+ createInputVar(),
+ createInputVar({
+ label: 'Output',
+ variable: '#$output.answer#',
+ value_selector: ['$output', 'answer'],
+ }),
+ {
+ ...createInputVar({
+ label: 'Broken',
+ }),
+ variable: undefined,
+ } as unknown as InputVar,
+ ])
+ mockFetchHumanInputNodeStepRunForm.mockResolvedValue(mockFormData)
+ mockSubmitHumanInputNodeStepRunForm.mockResolvedValue({})
+ })
+
+ it('should build a single before-run form, filter output vars, and expose dependent vars', () => {
+ const { result } = renderHook(() => useSingleRunFormParams({
+ id: 'node-1',
+ payload: currentInputs,
+ runInputData: { topic: 'AI' },
+ getInputVars,
+ setRunInputData: mockSetRunInputData,
+ }))
+
+ expect(getInputVars).toHaveBeenCalledWith([
+ '{{#start.topic#}}',
+ 'Summary: {{#start.topic#}}',
+ ])
+ expect(result.current.forms).toHaveLength(1)
+ expect(result.current.forms[0]).toEqual(expect.objectContaining({
+ label: 'nodes.humanInput.singleRun.label',
+ values: { topic: 'AI' },
+ inputs: [
+ expect.objectContaining({ variable: '#start.topic#' }),
+ expect.objectContaining({ label: 'Broken' }),
+ ],
+ }))
+
+ act(() => {
+ result.current.forms[0].onChange?.({ topic: 'Updated' })
+ })
+
+ expect(mockSetRunInputData).toHaveBeenCalledWith({ topic: 'Updated' })
+ expect(result.current.getDependentVars()).toEqual([
+ ['start', 'topic'],
+ ])
+ })
+
+ it('should fetch and submit generated forms in workflow mode while keeping required inputs', async () => {
+ const { result } = renderHook(() => useSingleRunFormParams({
+ id: 'node-1',
+ payload: currentInputs,
+ runInputData: {},
+ getInputVars,
+ setRunInputData: mockSetRunInputData,
+ }))
+
+ await act(async () => {
+ await result.current.handleShowGeneratedForm({
+ topic: 'AI',
+ ignored: undefined as unknown as string,
+ })
+ })
+
+ expect(result.current.showGeneratedForm).toBe(true)
+ expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledWith(
+ '/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
+ {
+ inputs: { topic: 'AI' },
+ },
+ )
+ expect(result.current.formData).toEqual(mockFormData)
+
+ await act(async () => {
+ await result.current.handleSubmitHumanInputForm({
+ inputs: { answer: 'approved' },
+ form_inputs: { ignored: 'value' },
+ action: 'approve',
+ })
+ })
+
+ expect(mockSubmitHumanInputNodeStepRunForm).toHaveBeenCalledWith(
+ '/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
+ {
+ inputs: { topic: 'AI' },
+ form_inputs: { answer: 'approved' },
+ action: 'approve',
+ },
+ )
+
+ act(() => {
+ result.current.handleHideGeneratedForm()
+ })
+
+ expect(result.current.showGeneratedForm).toBe(false)
+ })
+
+ it('should use the advanced-chat endpoint and skip remote fetches when app detail is missing', async () => {
+ appDetail = {
+ id: 'app-2',
+ mode: AppModeEnum.ADVANCED_CHAT,
+ }
+
+ const { result, rerender } = renderHook(() => useSingleRunFormParams({
+ id: 'node-9',
+ payload: currentInputs,
+ runInputData: {},
+ getInputVars,
+ setRunInputData: mockSetRunInputData,
+ }))
+
+ await act(async () => {
+ await result.current.handleFetchFormContent({ topic: 'hello' })
+ })
+
+ expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledWith(
+ '/apps/app-2/advanced-chat/workflows/draft/human-input/nodes/node-9/form',
+ {
+ inputs: { topic: 'hello' },
+ },
+ )
+
+ appDetail = undefined
+ rerender()
+
+ await act(async () => {
+ const data = await result.current.handleFetchFormContent({ topic: 'skip' })
+ expect(data).toBeNull()
+ })
+
+ expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..5bef3eb8a6
--- /dev/null
+++ b/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts
@@ -0,0 +1,173 @@
+import type { IterationNodeType } from '../types'
+import type { Item } from '@/app/components/base/select'
+import type { Var } from '@/app/components/workflow/types'
+import { act, renderHook } from '@testing-library/react'
+import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
+import { BlockEnum, ErrorHandleMode, VarType } from '@/app/components/workflow/types'
+import useConfig from '../use-config'
+
+const mockUseInspectVarsCrud = vi.hoisted(() => vi.fn())
+const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
+const mockUseIsChatMode = vi.hoisted(() => vi.fn())
+const mockUseWorkflow = vi.hoisted(() => vi.fn())
+const mockUseStore = vi.hoisted(() => vi.fn())
+const mockUseNodeCrud = vi.hoisted(() => vi.fn())
+const mockUseAllBuiltInTools = vi.hoisted(() => vi.fn())
+const mockUseAllCustomTools = vi.hoisted(() => vi.fn())
+const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn())
+const mockUseAllMCPTools = vi.hoisted(() => vi.fn())
+const mockToNodeOutputVars = vi.hoisted(() => vi.fn())
+
+vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseInspectVarsCrud(...args),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesReadOnly: () => mockUseNodesReadOnly(),
+ useIsChatMode: () => mockUseIsChatMode(),
+ useWorkflow: () => mockUseWorkflow(),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { dataSourceList: unknown[] }) => unknown) =>
+ selector({ dataSourceList: mockUseStore() }),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseNodeCrud(...args),
+}))
+
+vi.mock('@/service/use-tools', () => ({
+ useAllBuiltInTools: () => mockUseAllBuiltInTools(),
+ useAllCustomTools: () => mockUseAllCustomTools(),
+ useAllWorkflowTools: () => mockUseAllWorkflowTools(),
+ useAllMCPTools: () => mockUseAllMCPTools(),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
+ toNodeOutputVars: (...args: unknown[]) => mockToNodeOutputVars(...args),
+}))
+
+const createPayload = (overrides: Partial = {}): IterationNodeType => ({
+ title: 'Iteration',
+ desc: '',
+ type: BlockEnum.Iteration,
+ iterator_selector: ['start', 'items'],
+ iterator_input_type: VarType.arrayString,
+ output_selector: ['child', 'result'],
+ output_type: VarType.arrayString,
+ is_parallel: false,
+ parallel_nums: 3,
+ error_handle_mode: ErrorHandleMode.Terminated,
+ flatten_output: false,
+ start_node_id: 'start-node',
+ _children: [],
+ _isShowTips: false,
+ ...overrides,
+})
+
+const createVar = (type: VarType, variable = 'test.variable'): Var => ({
+ variable,
+ type,
+})
+
+describe('iteration/use-config', () => {
+ const mockSetInputs = vi.fn()
+ const mockDeleteNodeInspectorVars = vi.fn()
+ let currentInputs = createPayload()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentInputs = createPayload()
+
+ mockUseInspectVarsCrud.mockReturnValue({
+ deleteNodeInspectorVars: mockDeleteNodeInspectorVars,
+ })
+ mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
+ mockUseIsChatMode.mockReturnValue(false)
+ mockUseWorkflow.mockReturnValue({
+ getIterationNodeChildren: vi.fn(() => [{ id: 'child-node' }]),
+ })
+ mockUseStore.mockReturnValue([])
+ mockUseNodeCrud.mockImplementation(() => ({
+ inputs: currentInputs,
+ setInputs: mockSetInputs,
+ }))
+ mockUseAllBuiltInTools.mockReturnValue({ data: [] })
+ mockUseAllCustomTools.mockReturnValue({ data: [] })
+ mockUseAllWorkflowTools.mockReturnValue({ data: [] })
+ mockUseAllMCPTools.mockReturnValue({ data: [] })
+ mockToNodeOutputVars.mockReturnValue([{ variable: 'child.result' }])
+ })
+
+ it('should expose iteration children vars and filter only array-like iterator inputs', () => {
+ const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
+
+ expect(result.current.readOnly).toBe(false)
+ expect(result.current.childrenNodeVars).toEqual([{ variable: 'child.result' }])
+ expect(result.current.iterationChildrenNodes).toEqual([{ id: 'child-node' }])
+ expect(result.current.filterInputVar(createVar(VarType.arrayFile, 'files'))).toBe(true)
+ expect(result.current.filterInputVar(createVar(VarType.string, 'text'))).toBe(false)
+ expect(mockToNodeOutputVars).toHaveBeenCalled()
+ })
+
+ it('should update iterator input and output selectors and reset inspector vars on output changes', () => {
+ const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
+
+ act(() => {
+ result.current.handleInputChange(['start', 'documents'], VarKindType.variable, createVar(VarType.arrayObject, 'start.documents'))
+ })
+
+ expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
+ iterator_selector: ['start', 'documents'],
+ iterator_input_type: VarType.arrayObject,
+ }))
+
+ mockSetInputs.mockClear()
+
+ act(() => {
+ result.current.handleOutputVarChange(['child', 'score'], VarKindType.variable, createVar(VarType.number, 'child.score'))
+ })
+
+ expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
+ output_selector: ['child', 'score'],
+ output_type: VarType.arrayNumber,
+ }))
+ expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('iteration-node')
+
+ mockSetInputs.mockClear()
+
+ act(() => {
+ result.current.handleOutputVarChange(['child', 'result'], VarKindType.variable, createVar(VarType.string, 'child.result'))
+ })
+
+ expect(mockSetInputs).not.toHaveBeenCalled()
+ })
+
+ it('should update parallel, error-mode, and flatten options', () => {
+ const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
+ const item: Item = { name: 'Continue', value: ErrorHandleMode.ContinueOnError }
+
+ act(() => {
+ result.current.changeParallel(true)
+ result.current.changeErrorResponseMode(item)
+ result.current.changeParallelNums(6)
+ result.current.changeFlattenOutput(true)
+ })
+
+ expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ is_parallel: true,
+ }))
+ expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ error_handle_mode: ErrorHandleMode.ContinueOnError,
+ }))
+ expect(mockSetInputs).toHaveBeenNthCalledWith(3, expect.objectContaining({
+ parallel_nums: 6,
+ }))
+ expect(mockSetInputs).toHaveBeenNthCalledWith(4, expect.objectContaining({
+ flatten_output: true,
+ }))
+ })
+})
diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts
new file mode 100644
index 0000000000..7313b6945e
--- /dev/null
+++ b/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts
@@ -0,0 +1,168 @@
+import type { InputVar, Node } from '../../../types'
+import type { IterationNodeType } from '../types'
+import type { NodeTracing } from '@/types/workflow'
+import { act, renderHook } from '@testing-library/react'
+import { BlockEnum, ErrorHandleMode, InputVarType, VarType } from '@/app/components/workflow/types'
+import useSingleRunFormParams from '../use-single-run-form-params'
+
+const mockUseIsNodeInIteration = vi.hoisted(() => vi.fn())
+const mockUseWorkflow = vi.hoisted(() => vi.fn())
+const mockFormatTracing = vi.hoisted(() => vi.fn())
+const mockGetNodeUsedVars = vi.hoisted(() => vi.fn())
+const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn())
+const mockGetNodeInfoById = vi.hoisted(() => vi.fn())
+const mockIsSystemVar = vi.hoisted(() => vi.fn())
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useIsNodeInIteration: (...args: unknown[]) => mockUseIsNodeInIteration(...args),
+ useWorkflow: () => mockUseWorkflow(),
+}))
+
+vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatTracing(...args),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
+ getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args),
+ getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args),
+ getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args),
+ isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args),
+}))
+
+const createInputVar = (variable: string): InputVar => ({
+ type: InputVarType.textInput,
+ label: variable,
+ variable,
+ required: false,
+})
+
+const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({
+ id,
+ position: { x: 0, y: 0 },
+ data: {
+ title,
+ type,
+ desc: '',
+ },
+} as Node)
+
+const createPayload = (overrides: Partial = {}): IterationNodeType => ({
+ title: 'Iteration',
+ desc: '',
+ type: BlockEnum.Iteration,
+ start_node_id: 'start-node',
+ iterator_selector: ['start-node', 'items'],
+ iterator_input_type: VarType.arrayString,
+ output_selector: ['child-node', 'text'],
+ output_type: VarType.arrayString,
+ is_parallel: false,
+ parallel_nums: 2,
+ error_handle_mode: ErrorHandleMode.Terminated,
+ flatten_output: false,
+ _children: [],
+ _isShowTips: false,
+ ...overrides,
+})
+
+describe('iteration/use-single-run-form-params', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseIsNodeInIteration.mockReturnValue({
+ isNodeInIteration: (nodeId: string) => nodeId === 'inner-node',
+ })
+ mockUseWorkflow.mockReturnValue({
+ getIterationNodeChildren: () => [
+ createNode('tool-a', 'Tool A'),
+ createNode('inner-node', 'Inner Node'),
+ ],
+ getBeforeNodesInSameBranch: () => [
+ createNode('start-node', 'Start Node', BlockEnum.Start),
+ ],
+ })
+ mockGetNodeUsedVars.mockImplementation((node: Node) => {
+ if (node.id === 'tool-a')
+ return [['start-node', 'answer'], ['inner-node', 'secret'], ['iteration-node', 'item']]
+ return []
+ })
+ mockGetNodeUsedVarPassToServerKey.mockReturnValue('passed_key')
+ mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id))
+ mockIsSystemVar.mockReturnValue(false)
+ mockFormatTracing.mockReturnValue([{ id: 'formatted-node' }])
+ })
+
+ it('should build single-run forms from external vars and keep iterator state in a dedicated form', () => {
+ const toVarInputs = vi.fn(() => [createInputVar('#start-node.answer#')])
+
+ const { result } = renderHook(() => useSingleRunFormParams({
+ id: 'iteration-node',
+ payload: createPayload(),
+ runInputData: {
+ 'query': 'hello',
+ 'iteration-node.input_selector': ['start-node', 'items'],
+ },
+ runInputDataRef: { current: {} },
+ getInputVars: vi.fn(),
+ setRunInputData: vi.fn(),
+ toVarInputs,
+ iterationRunResult: [],
+ }))
+
+ expect(toVarInputs).toHaveBeenCalledWith([
+ expect.objectContaining({
+ variable: 'start-node.answer',
+ value_selector: ['start-node', 'answer'],
+ }),
+ ])
+ expect(result.current.forms).toHaveLength(2)
+ expect(result.current.forms[0].inputs).toEqual([createInputVar('#start-node.answer#')])
+ expect(result.current.forms[0].values).toEqual({
+ 'query': 'hello',
+ 'iteration-node.input_selector': ['start-node', 'items'],
+ })
+ expect(result.current.forms[1].values).toEqual({
+ 'iteration-node.input_selector': ['start-node', 'items'],
+ })
+ expect(result.current.allVarObject).toEqual({
+ 'start-node.answer@@@tool-a@@@0': {
+ inSingleRunPassedKey: 'passed_key',
+ },
+ })
+ expect(result.current.nodeInfo).toEqual({ id: 'formatted-node' })
+ })
+
+ it('should forward form updates and expose iterator dependencies', () => {
+ const setRunInputData = vi.fn()
+
+ const { result } = renderHook(() => useSingleRunFormParams({
+ id: 'iteration-node',
+ payload: createPayload({
+ iterator_selector: ['source-node', 'records'],
+ }),
+ runInputData: {
+ 'query': 'old',
+ 'iteration-node.input_selector': ['source-node', 'records'],
+ },
+ runInputDataRef: { current: {} },
+ getInputVars: vi.fn(),
+ setRunInputData,
+ toVarInputs: vi.fn(() => []),
+ iterationRunResult: [] as NodeTracing[],
+ }))
+
+ act(() => {
+ result.current.forms[0].onChange({ query: 'new' })
+ result.current.forms[1].onChange({
+ 'iteration-node.input_selector': ['source-node', 'next'],
+ })
+ })
+
+ expect(setRunInputData).toHaveBeenNthCalledWith(1, { query: 'new' })
+ expect(setRunInputData).toHaveBeenNthCalledWith(2, {
+ 'query': 'old',
+ 'iteration-node.input_selector': ['source-node', 'next'],
+ })
+ expect(result.current.getDependentVars()).toEqual([['source-node', 'records']])
+ expect(result.current.getDependentVar('iteration-node.input_selector')).toEqual(['source-node', 'records'])
+ })
+})
diff --git a/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..6ced0f3511
--- /dev/null
+++ b/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts
@@ -0,0 +1,238 @@
+import type { StartNodeType } from '../types'
+import type { InputVar, ValueSelector } from '@/app/components/workflow/types'
+import { act, renderHook } from '@testing-library/react'
+import Toast from '@/app/components/base/toast'
+import { BlockEnum, ChangeType, InputVarType } from '@/app/components/workflow/types'
+import useConfig from '../use-config'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
+const mockUseWorkflow = vi.hoisted(() => vi.fn())
+const mockUseIsChatMode = vi.hoisted(() => vi.fn())
+const mockUseNodeCrud = vi.hoisted(() => vi.fn())
+const mockUseInspectVarsCrud = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesReadOnly: () => mockUseNodesReadOnly(),
+ useWorkflow: () => mockUseWorkflow(),
+ useIsChatMode: () => mockUseIsChatMode(),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseNodeCrud(...args),
+}))
+
+vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseInspectVarsCrud(...args),
+}))
+
+const createInputVar = (overrides: Partial = {}): InputVar => ({
+ label: 'Question',
+ variable: 'query',
+ type: InputVarType.textInput,
+ required: true,
+ ...overrides,
+})
+
+const createPayload = (overrides: Partial = {}): StartNodeType => ({
+ title: 'Start',
+ desc: '',
+ type: BlockEnum.Start,
+ variables: [
+ createInputVar(),
+ createInputVar({
+ label: 'Age',
+ variable: 'age',
+ type: InputVarType.number,
+ required: false,
+ }),
+ ],
+ ...overrides,
+})
+
+describe('start/use-config', () => {
+ const mockSetInputs = vi.fn()
+ const mockHandleOutVarRenameChange = vi.fn()
+ const mockIsVarUsedInNodes = vi.fn()
+ const mockRemoveUsedVarInNodes = vi.fn()
+ const mockDeleteNodeInspectorVars = vi.fn()
+ const mockRenameInspectVarName = vi.fn()
+ const mockDeleteInspectVar = vi.fn()
+ const toastSpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
+ let currentInputs: StartNodeType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentInputs = createPayload()
+
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
+ mockUseWorkflow.mockReturnValue({
+ handleOutVarRenameChange: mockHandleOutVarRenameChange,
+ isVarUsedInNodes: mockIsVarUsedInNodes,
+ removeUsedVarInNodes: mockRemoveUsedVarInNodes,
+ })
+ mockUseIsChatMode.mockReturnValue(false)
+ mockUseNodeCrud.mockImplementation(() => ({
+ inputs: currentInputs,
+ setInputs: mockSetInputs,
+ }))
+ mockUseInspectVarsCrud.mockReturnValue({
+ deleteNodeInspectorVars: mockDeleteNodeInspectorVars,
+ renameInspectVarName: mockRenameInspectVarName,
+ nodesWithInspectVars: [{
+ nodeId: 'start-node',
+ vars: [{ id: 'inspect-query', name: 'query' }],
+ }],
+ deleteInspectVar: mockDeleteInspectVar,
+ })
+ mockIsVarUsedInNodes.mockReturnValue(false)
+ })
+
+ it('should rename variables and sync downstream variable references', () => {
+ const { result } = renderHook(() => useConfig('start-node', currentInputs))
+ const renamedList = [
+ createInputVar({
+ label: 'Question',
+ variable: 'prompt',
+ }),
+ createInputVar({
+ label: 'Age',
+ variable: 'age',
+ type: InputVarType.number,
+ required: false,
+ }),
+ ]
+
+ act(() => {
+ result.current.handleVarListChange(renamedList, {
+ index: 0,
+ payload: {
+ type: ChangeType.changeVarName,
+ payload: {
+ beforeKey: 'query',
+ },
+ },
+ })
+ })
+
+ expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
+ variables: renamedList,
+ }))
+ expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith('start-node', ['start-node', 'query'], ['start-node', 'prompt'])
+ expect(mockRenameInspectVarName).toHaveBeenCalledWith('start-node', 'query', 'prompt')
+ expect(result.current.readOnly).toBe(false)
+ expect(result.current.isChatMode).toBe(false)
+ })
+
+ it('should block removal when the variable is still in use and confirm the deletion later', () => {
+ mockIsVarUsedInNodes.mockReturnValue(true)
+ const { result } = renderHook(() => useConfig('start-node', currentInputs))
+ const nextList = [currentInputs.variables[1]]
+
+ act(() => {
+ result.current.handleVarListChange(nextList, {
+ index: 0,
+ payload: {
+ type: ChangeType.remove,
+ payload: {
+ beforeKey: 'query',
+ },
+ },
+ })
+ })
+
+ expect(mockDeleteInspectVar).toHaveBeenCalledWith('start-node', 'inspect-query')
+ expect(mockSetInputs).not.toHaveBeenCalled()
+ expect(result.current.isShowRemoveVarConfirm).toBe(true)
+
+ act(() => {
+ result.current.onRemoveVarConfirm()
+ })
+
+ expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
+ variables: [expect.objectContaining({ variable: 'age' })],
+ }))
+ expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['start-node', 'query'] as ValueSelector)
+ expect(result.current.isShowRemoveVarConfirm).toBe(false)
+ })
+
+ it('should validate duplicate variables and labels before adding a new variable', () => {
+ const { result } = renderHook(() => useConfig('start-node', currentInputs))
+
+ let added = true
+ act(() => {
+ added = result.current.handleAddVariable(createInputVar({
+ label: 'Different Label',
+ variable: 'query',
+ }))
+ })
+
+ expect(added).toBe(false)
+ expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'varKeyError.keyAlreadyExists',
+ }))
+
+ mockSetInputs.mockClear()
+ let addedUnique = false
+ act(() => {
+ addedUnique = result.current.handleAddVariable(createInputVar({
+ label: 'Locale',
+ variable: 'locale',
+ required: false,
+ }))
+ })
+
+ expect(addedUnique).toBe(true)
+ expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
+ variables: expect.arrayContaining([
+ expect.objectContaining({ variable: 'locale' }),
+ ]),
+ }))
+ })
+
+ it('should clear inspector vars for non-remove list updates and reject duplicate labels', () => {
+ const { result } = renderHook(() => useConfig('start-node', currentInputs))
+ const typeEditedList = [
+ createInputVar({
+ label: 'Question',
+ variable: 'query',
+ type: InputVarType.paragraph,
+ }),
+ currentInputs.variables[1],
+ ]
+
+ act(() => {
+ result.current.handleVarListChange(typeEditedList)
+ })
+
+ expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
+ variables: typeEditedList,
+ }))
+ expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('start-node')
+
+ toastSpy.mockClear()
+ let added = true
+ act(() => {
+ added = result.current.handleAddVariable(createInputVar({
+ label: 'Age',
+ variable: 'new_age',
+ }))
+ })
+
+ expect(added).toBe(false)
+ expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'varKeyError.keyAlreadyExists',
+ }))
+ })
+})
diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts b/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts
new file mode 100644
index 0000000000..0cbb98c96a
--- /dev/null
+++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts
@@ -0,0 +1,244 @@
+import { act, renderHook } from '@testing-library/react'
+import { VarType } from '../../../types'
+import { useGetAvailableVars, useVariableAssigner } from '../hooks'
+
+const mockUseStoreApi = vi.hoisted(() => vi.fn())
+const mockUseNodes = vi.hoisted(() => vi.fn())
+const mockUseNodeDataUpdate = vi.hoisted(() => vi.fn())
+const mockUseWorkflow = vi.hoisted(() => vi.fn())
+const mockUseWorkflowVariables = vi.hoisted(() => vi.fn())
+const mockUseIsChatMode = vi.hoisted(() => vi.fn())
+const mockUseWorkflowStore = vi.hoisted(() => vi.fn())
+
+vi.mock('reactflow', () => ({
+ useStoreApi: () => mockUseStoreApi(),
+ useNodes: () => mockUseNodes(),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodeDataUpdate: () => mockUseNodeDataUpdate(),
+ useWorkflow: () => mockUseWorkflow(),
+ useWorkflowVariables: () => mockUseWorkflowVariables(),
+ useIsChatMode: () => mockUseIsChatMode(),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => mockUseWorkflowStore(),
+}))
+
+describe('variable-assigner/hooks', () => {
+ const mockHandleNodeDataUpdate = vi.fn()
+ const mockSetNodes = vi.fn()
+ const mockSetShowAssignVariablePopup = vi.fn()
+ const mockSetHoveringAssignVariableGroupId = vi.fn()
+ const getNodes = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ getNodes.mockReturnValue([{
+ id: 'assigner-1',
+ data: {
+ variables: [['start', 'foo']],
+ output_type: VarType.string,
+ advanced_settings: {
+ groups: [{
+ groupId: 'group-1',
+ variables: [],
+ output_type: VarType.string,
+ }],
+ },
+ },
+ }])
+ mockUseStoreApi.mockReturnValue({
+ getState: () => ({
+ getNodes,
+ setNodes: mockSetNodes,
+ }),
+ })
+ mockUseNodeDataUpdate.mockReturnValue({
+ handleNodeDataUpdate: mockHandleNodeDataUpdate,
+ })
+ mockUseWorkflowStore.mockReturnValue({
+ getState: () => ({
+ setShowAssignVariablePopup: mockSetShowAssignVariablePopup,
+ setHoveringAssignVariableGroupId: mockSetHoveringAssignVariableGroupId,
+ connectingNodePayload: { id: 'connecting-node' },
+ }),
+ })
+ mockUseNodes.mockReturnValue([])
+ mockUseWorkflow.mockReturnValue({
+ getBeforeNodesInSameBranchIncludeParent: vi.fn(),
+ })
+ mockUseWorkflowVariables.mockReturnValue({
+ getNodeAvailableVars: vi.fn(),
+ })
+ mockUseIsChatMode.mockReturnValue(false)
+ })
+
+ it('should append target variables, ignore duplicates, and update grouped variables', () => {
+ const { result } = renderHook(() => useVariableAssigner())
+
+ act(() => {
+ result.current.handleAssignVariableValueChange('assigner-1', ['start', 'bar'], { type: VarType.number } as never)
+ result.current.handleAssignVariableValueChange('assigner-1', ['start', 'foo'], { type: VarType.number } as never)
+ result.current.handleAssignVariableValueChange('assigner-1', ['start', 'grouped'], { type: VarType.arrayString } as never, 'group-1')
+ })
+
+ expect(mockHandleNodeDataUpdate).toHaveBeenNthCalledWith(1, {
+ id: 'assigner-1',
+ data: {
+ variables: [
+ ['start', 'foo'],
+ ['start', 'bar'],
+ ],
+ output_type: VarType.number,
+ },
+ })
+ expect(mockHandleNodeDataUpdate).toHaveBeenNthCalledWith(2, {
+ id: 'assigner-1',
+ data: {
+ advanced_settings: {
+ groups: [{
+ groupId: 'group-1',
+ variables: [['start', 'grouped']],
+ output_type: VarType.arrayString,
+ }],
+ },
+ },
+ })
+ expect(mockHandleNodeDataUpdate).toHaveBeenCalledTimes(2)
+ })
+
+ it('should close the popup and add variables through the positioned add-variable flow', () => {
+ getNodes.mockReturnValue([
+ {
+ id: 'source-node',
+ data: {
+ _showAddVariablePopup: true,
+ _holdAddVariablePopup: true,
+ },
+ },
+ {
+ id: 'assigner-1',
+ data: {
+ variables: [],
+ advanced_settings: {
+ groups: [{
+ groupId: 'group-1',
+ variables: [],
+ }],
+ },
+ _showAddVariablePopup: true,
+ _holdAddVariablePopup: true,
+ },
+ },
+ ])
+
+ const { result } = renderHook(() => useVariableAssigner())
+
+ act(() => {
+ result.current.handleAddVariableInAddVariablePopupWithPosition(
+ 'source-node',
+ 'assigner-1',
+ 'group-1',
+ ['start', 'output'],
+ { type: VarType.object } as never,
+ )
+ })
+
+ expect(mockSetNodes).toHaveBeenCalledWith([
+ expect.objectContaining({
+ id: 'source-node',
+ data: expect.objectContaining({
+ _showAddVariablePopup: false,
+ _holdAddVariablePopup: false,
+ }),
+ }),
+ expect.objectContaining({
+ id: 'assigner-1',
+ data: expect.objectContaining({
+ _showAddVariablePopup: false,
+ _holdAddVariablePopup: false,
+ }),
+ }),
+ ])
+ expect(mockSetShowAssignVariablePopup).toHaveBeenCalledWith(undefined)
+ expect(mockHandleNodeDataUpdate).toHaveBeenCalledWith({
+ id: 'assigner-1',
+ data: {
+ advanced_settings: {
+ groups: [{
+ groupId: 'group-1',
+ variables: [['start', 'output']],
+ output_type: VarType.object,
+ }],
+ },
+ },
+ })
+ })
+
+ it('should update the hovered group state on enter and leave', () => {
+ const { result } = renderHook(() => useVariableAssigner())
+
+ act(() => {
+ result.current.handleGroupItemMouseEnter('group-1')
+ result.current.handleGroupItemMouseLeave()
+ })
+
+ expect(mockSetHoveringAssignVariableGroupId).toHaveBeenNthCalledWith(1, 'group-1')
+ expect(mockSetHoveringAssignVariableGroupId).toHaveBeenNthCalledWith(2, undefined)
+ })
+
+ it('should collect available vars and filter start-node env vars when hideEnv is enabled', () => {
+ mockUseNodes.mockReturnValue([
+ {
+ id: 'current-node',
+ parentId: 'parent-node',
+ },
+ {
+ id: 'before-1',
+ },
+ {
+ id: 'parent-node',
+ },
+ ])
+ const getBeforeNodesInSameBranchIncludeParent = vi.fn(() => [
+ { id: 'before-1' },
+ { id: 'before-1' },
+ ])
+ const getNodeAvailableVars = vi.fn()
+ .mockReturnValueOnce([{
+ isStartNode: true,
+ vars: [
+ { variable: 'sys.user_id' },
+ { variable: 'foo' },
+ ],
+ }, {
+ isStartNode: false,
+ vars: [],
+ }])
+ .mockReturnValueOnce([{
+ isStartNode: false,
+ vars: [{ variable: 'bar' }],
+ }])
+
+ mockUseWorkflow.mockReturnValue({
+ getBeforeNodesInSameBranchIncludeParent,
+ })
+ mockUseWorkflowVariables.mockReturnValue({
+ getNodeAvailableVars,
+ })
+
+ const { result } = renderHook(() => useGetAvailableVars())
+
+ expect(result.current('current-node', 'target', () => true, true)).toEqual([{
+ isStartNode: true,
+ vars: [{ variable: 'foo' }],
+ }])
+ expect(result.current('current-node', 'target', () => true, false)).toEqual([{
+ isStartNode: false,
+ vars: [{ variable: 'bar' }],
+ }])
+ expect(result.current('missing-node', 'target', () => true)).toEqual([])
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/hooks.spec.ts b/web/app/components/workflow/run/__tests__/hooks.spec.ts
new file mode 100644
index 0000000000..d6eefbcd3e
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/hooks.spec.ts
@@ -0,0 +1,127 @@
+import type {
+ AgentLogItemWithChildren,
+ IterationDurationMap,
+ LoopDurationMap,
+ LoopVariableMap,
+ NodeTracing,
+} from '@/types/workflow'
+import { act, renderHook } from '@testing-library/react'
+import { BlockEnum } from '../../types'
+import { useLogs } from '../hooks'
+
+const createNodeTracing = (id: string): NodeTracing => ({
+ id,
+ index: 0,
+ predecessor_node_id: '',
+ node_id: id,
+ node_type: BlockEnum.Tool,
+ title: id,
+ inputs: {},
+ inputs_truncated: false,
+ process_data: {},
+ process_data_truncated: false,
+ outputs_truncated: false,
+ status: 'succeeded',
+ elapsed_time: 1,
+ metadata: {
+ iterator_length: 0,
+ iterator_index: 0,
+ loop_length: 0,
+ loop_index: 0,
+ },
+ created_at: 0,
+ created_by: {
+ id: 'user-1',
+ name: 'User',
+ email: 'user@example.com',
+ },
+ finished_at: 1,
+})
+
+const createAgentLog = (id: string, children: AgentLogItemWithChildren[] = []): AgentLogItemWithChildren => ({
+ node_execution_id: `execution-${id}`,
+ node_id: `node-${id}`,
+ parent_id: undefined,
+ label: id,
+ status: 'success',
+ data: {},
+ metadata: {},
+ message_id: id,
+ children,
+})
+
+describe('useLogs', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should manage retry, iteration, and loop detail panels', () => {
+ const { result } = renderHook(() => useLogs())
+ const retryDetail = [createNodeTracing('retry-node')]
+ const iterationDetail = [[createNodeTracing('iteration-node')]]
+ const loopDetail = [[createNodeTracing('loop-node')]]
+ const iterationDurationMap: IterationDurationMap = { 'iteration-node': 2 }
+ const loopDurationMap: LoopDurationMap = { 'loop-node': 3 }
+ const loopVariableMap: LoopVariableMap = { 'loop-node': { item: 'value' } }
+
+ expect(result.current.showSpecialResultPanel).toBe(false)
+
+ act(() => {
+ result.current.handleShowRetryResultList(retryDetail)
+ })
+
+ expect(result.current.showRetryDetail).toBe(true)
+ expect(result.current.retryResultList).toEqual(retryDetail)
+ expect(result.current.showSpecialResultPanel).toBe(true)
+
+ act(() => {
+ result.current.setShowRetryDetailFalse()
+ result.current.handleShowIterationResultList(iterationDetail, iterationDurationMap)
+ result.current.handleShowLoopResultList(loopDetail, loopDurationMap, loopVariableMap)
+ })
+
+ expect(result.current.showRetryDetail).toBe(false)
+ expect(result.current.showIteratingDetail).toBe(true)
+ expect(result.current.iterationResultList).toEqual(iterationDetail)
+ expect(result.current.iterationResultDurationMap).toEqual(iterationDurationMap)
+ expect(result.current.showLoopingDetail).toBe(true)
+ expect(result.current.loopResultList).toEqual(loopDetail)
+ expect(result.current.loopResultDurationMap).toEqual(loopDurationMap)
+ expect(result.current.loopResultVariableMap).toEqual(loopVariableMap)
+ })
+
+ it('should push, trim, and clear agent/tool log navigation state', () => {
+ const { result } = renderHook(() => useLogs())
+ const childLog = createAgentLog('child-log')
+ const rootLog = createAgentLog('root-log', [childLog])
+ const siblingLog = createAgentLog('sibling-log')
+
+ act(() => {
+ result.current.handleShowAgentOrToolLog(rootLog)
+ })
+
+ expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
+ expect(result.current.agentOrToolLogListMap).toEqual({
+ 'root-log': [childLog],
+ })
+ expect(result.current.showSpecialResultPanel).toBe(true)
+
+ act(() => {
+ result.current.handleShowAgentOrToolLog(siblingLog)
+ })
+
+ expect(result.current.agentOrToolLogItemStack).toEqual([rootLog, siblingLog])
+
+ act(() => {
+ result.current.handleShowAgentOrToolLog(rootLog)
+ })
+
+ expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
+
+ act(() => {
+ result.current.handleShowAgentOrToolLog(undefined)
+ })
+
+ expect(result.current.agentOrToolLogItemStack).toEqual([])
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/result-panel.spec.tsx b/web/app/components/workflow/run/__tests__/result-panel.spec.tsx
new file mode 100644
index 0000000000..ea8606d74e
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/result-panel.spec.tsx
@@ -0,0 +1,356 @@
+import type { ReactNode } from 'react'
+import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { BlockEnum, NodeRunningStatus } from '../../types'
+import ResultPanel from '../result-panel'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockCodeEditor = vi.hoisted(() => vi.fn())
+const mockLargeDataAlert = vi.hoisted(() => vi.fn())
+const mockStatusPanel = vi.hoisted(() => vi.fn())
+const mockMetaData = vi.hoisted(() => vi.fn())
+const mockErrorHandleTip = vi.hoisted(() => vi.fn())
+const mockIterationLogTrigger = vi.hoisted(() => vi.fn())
+const mockLoopLogTrigger = vi.hoisted(() => vi.fn())
+const mockRetryLogTrigger = vi.hoisted(() => vi.fn())
+const mockAgentLogTrigger = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
+ __esModule: true,
+ default: (props: {
+ title: ReactNode
+ value: unknown
+ footer?: ReactNode
+ tip?: ReactNode
+ }) => {
+ mockCodeEditor(props)
+ return (
+
+ {props.title}
+ {typeof props.value === 'string' ? props.value : JSON.stringify(props.value)}
+ {props.tip}
+ {props.footer}
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip', () => ({
+ __esModule: true,
+ default: ({ type }: { type?: string }) => {
+ mockErrorHandleTip(type)
+ return {type}
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/iteration-log', () => ({
+ IterationLogTrigger: (props: {
+ onShowIterationResultList: (detail: unknown, durationMap: unknown) => void
+ nodeInfo: { details?: unknown, iterDurationMap?: unknown }
+ }) => {
+ mockIterationLogTrigger(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/loop-log', () => ({
+ LoopLogTrigger: (props: {
+ onShowLoopResultList: (detail: unknown, durationMap: unknown) => void
+ nodeInfo: { details?: unknown, loopDurationMap?: unknown }
+ }) => {
+ mockLoopLogTrigger(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/retry-log', () => ({
+ RetryLogTrigger: (props: {
+ onShowRetryResultList: (detail: unknown) => void
+ nodeInfo: { retryDetail?: unknown }
+ }) => {
+ mockRetryLogTrigger(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/agent-log', () => ({
+ AgentLogTrigger: (props: {
+ onShowAgentOrToolLog: (detail: unknown) => void
+ nodeInfo: { agentLog?: unknown }
+ }) => {
+ mockAgentLogTrigger(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/variable-inspect/large-data-alert', () => ({
+ __esModule: true,
+ default: (props: { downloadUrl?: string }) => {
+ mockLargeDataAlert(props)
+ return {props.downloadUrl ?? 'no-download'}
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/meta', () => ({
+ __esModule: true,
+ default: (props: Record) => {
+ mockMetaData(props)
+ return {JSON.stringify(props)}
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/status', () => ({
+ __esModule: true,
+ default: (props: Record) => {
+ mockStatusPanel(props)
+ return {JSON.stringify(props)}
+ },
+}))
+
+const createNodeInfo = (overrides: Partial = {}): NodeTracing => ({
+ id: 'trace-node-1',
+ index: 0,
+ predecessor_node_id: '',
+ node_id: 'node-1',
+ node_type: BlockEnum.Code,
+ title: 'Code',
+ inputs: {},
+ inputs_truncated: false,
+ process_data: {},
+ process_data_truncated: false,
+ outputs_truncated: false,
+ status: NodeRunningStatus.Succeeded,
+ elapsed_time: 0,
+ metadata: {
+ iterator_length: 0,
+ iterator_index: 0,
+ loop_length: 0,
+ loop_index: 0,
+ },
+ created_at: 0,
+ created_by: {
+ id: 'user-1',
+ name: 'User',
+ email: 'user@example.com',
+ },
+ finished_at: 1,
+ details: undefined,
+ retryDetail: undefined,
+ agentLog: undefined,
+ iterDurationMap: undefined,
+ loopDurationMap: undefined,
+ ...overrides,
+})
+
+const createLogDetail = (id: string): NodeTracing => createNodeInfo({
+ id: `trace-${id}`,
+ node_id: id,
+ title: id,
+})
+
+const createAgentLog = (label: string): AgentLogItemWithChildren => ({
+ node_execution_id: `execution-${label}`,
+ message_id: `message-${label}`,
+ node_id: `node-${label}`,
+ parent_id: undefined,
+ label,
+ status: 'success',
+ data: {},
+ metadata: {},
+ children: [],
+})
+
+describe('ResultPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ })
+
+ it('should render status, editors, alerts, error strategy tip, and metadata', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('status-panel')).toBeInTheDocument()
+ expect(screen.getByText('COMMON.INPUT')).toBeInTheDocument()
+ expect(screen.getByText('COMMON.PROCESSDATA')).toBeInTheDocument()
+ expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
+ expect(screen.getAllByTestId('code-editor')).toHaveLength(3)
+ expect(screen.getAllByTestId('large-data-alert')).toHaveLength(3)
+ expect(screen.getByTestId('error-handle-tip')).toHaveTextContent('continue-on-error')
+ expect(screen.getByTestId('meta-data')).toBeInTheDocument()
+ expect(mockStatusPanel).toHaveBeenCalledWith(expect.objectContaining({
+ status: NodeRunningStatus.Succeeded,
+ time: 2.5,
+ tokens: 42,
+ error: 'boom',
+ exceptionCounts: 1,
+ isListening: true,
+ workflowRunId: 'run-1',
+ }))
+ expect(mockMetaData).toHaveBeenCalledWith(expect.objectContaining({
+ status: NodeRunningStatus.Succeeded,
+ executor: 'Alice',
+ startTime: 1710000000,
+ time: 2.5,
+ tokens: 42,
+ steps: 3,
+ showSteps: true,
+ }))
+ expect(mockLargeDataAlert).toHaveBeenLastCalledWith(expect.objectContaining({
+ downloadUrl: 'https://example.com/output.json',
+ }))
+ })
+
+ it('should render and invoke iteration and loop triggers only when their handlers are provided', () => {
+ const handleShowIterationResultList = vi.fn()
+ const handleShowLoopResultList = vi.fn()
+ const details = [[createLogDetail('iter-1')]]
+
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'iteration-trigger' }))
+ expect(handleShowIterationResultList).toHaveBeenCalledWith(details, { 0: 3 })
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'loop-trigger' }))
+ expect(handleShowLoopResultList).toHaveBeenCalledWith(details, { 0: 5 })
+ })
+
+ it('should render retry and agent/tool triggers when the node shape supports them', () => {
+ const onShowRetryDetail = vi.fn()
+ const handleShowAgentOrToolLog = vi.fn()
+ const retryDetail = [createLogDetail('retry-1')]
+ const agentLog = [createAgentLog('tool-call')]
+
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'retry-trigger' }))
+ expect(onShowRetryDetail).toHaveBeenCalledWith(retryDetail)
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
+ expect(handleShowAgentOrToolLog).toHaveBeenCalledWith(agentLog)
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
+ expect(handleShowAgentOrToolLog).toHaveBeenLastCalledWith(agentLog)
+ })
+
+ it('should still render the output editor while the node is running even without outputs', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx b/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx
new file mode 100644
index 0000000000..f5445f5f9f
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx
@@ -0,0 +1,199 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { getHoveredParallelId } from '../get-hovered-parallel-id'
+import TracingPanel from '../tracing-panel'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockFormatNodeList = vi.hoisted(() => vi.fn())
+const mockUseLogs = vi.hoisted(() => vi.fn())
+const mockNodePanel = vi.hoisted(() => vi.fn())
+const mockSpecialResultPanel = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatNodeList(...args),
+}))
+
+vi.mock('../hooks', () => ({
+ useLogs: () => mockUseLogs(),
+}))
+
+vi.mock('../node', () => ({
+ __esModule: true,
+ default: (props: {
+ nodeInfo: { id: string }
+ }) => {
+ mockNodePanel(props)
+ return {props.nodeInfo.id}
+ },
+}))
+
+vi.mock('../special-result-panel', () => ({
+ __esModule: true,
+ default: (props: Record) => {
+ mockSpecialResultPanel(props)
+ return special
+ },
+}))
+
+describe('TracingPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ mockUseLogs.mockReturnValue({
+ showSpecialResultPanel: false,
+ showRetryDetail: false,
+ setShowRetryDetailFalse: vi.fn(),
+ retryResultList: [],
+ handleShowRetryResultList: vi.fn(),
+ showIteratingDetail: false,
+ setShowIteratingDetailFalse: vi.fn(),
+ iterationResultList: [],
+ iterationResultDurationMap: {},
+ handleShowIterationResultList: vi.fn(),
+ showLoopingDetail: false,
+ setShowLoopingDetailFalse: vi.fn(),
+ loopResultList: [],
+ loopResultDurationMap: {},
+ loopResultVariableMap: {},
+ handleShowLoopResultList: vi.fn(),
+ agentOrToolLogItemStack: [],
+ agentOrToolLogListMap: {},
+ handleShowAgentOrToolLog: vi.fn(),
+ })
+ })
+
+ it('should render formatted nodes, preserve branch labels, and collapse parallel groups', () => {
+ mockFormatNodeList.mockReturnValue([
+ {
+ id: 'parallel-1',
+ parallelDetail: {
+ isParallelStartNode: true,
+ parallelTitle: 'Parallel Group',
+ children: [{
+ id: 'child-1',
+ title: 'Child Node',
+ parallelDetail: {
+ branchTitle: 'Branch A',
+ },
+ }],
+ },
+ },
+ {
+ id: 'node-2',
+ title: 'Standalone Node',
+ parallelDetail: {
+ branchTitle: 'Branch B',
+ },
+ },
+ ])
+
+ const parentClick = vi.fn()
+ const { container } = render(
+
+
+
,
+ )
+
+ expect(screen.getByText('Parallel Group')).toBeInTheDocument()
+ expect(screen.getByText('Branch A')).toBeInTheDocument()
+ expect(screen.getByText('Branch B')).toBeInTheDocument()
+ expect(screen.getByTestId('node-child-1')).toBeInTheDocument()
+ expect(screen.getByTestId('node-node-2')).toBeInTheDocument()
+
+ fireEvent.click(container.querySelector('.py-2') as HTMLElement)
+ expect(parentClick).not.toHaveBeenCalled()
+
+ const hoverTarget = screen.getByText('Parallel Group').closest('[data-parallel-id="parallel-1"]') as HTMLElement
+ const nestedParallelTarget = document.createElement('div')
+ nestedParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
+ const unrelatedTarget = document.createElement('div')
+ document.body.appendChild(nestedParallelTarget)
+ document.body.appendChild(unrelatedTarget)
+
+ fireEvent.mouseEnter(hoverTarget)
+ const sameParallelOut = new MouseEvent('mouseout', { bubbles: true })
+ Object.defineProperty(sameParallelOut, 'relatedTarget', { value: nestedParallelTarget })
+ hoverTarget.dispatchEvent(sameParallelOut)
+
+ const differentTargetOut = new MouseEvent('mouseout', { bubbles: true })
+ Object.defineProperty(differentTargetOut, 'relatedTarget', { value: unrelatedTarget })
+ hoverTarget.dispatchEvent(differentTargetOut)
+
+ fireEvent.mouseLeave(hoverTarget)
+
+ fireEvent.click(screen.getAllByRole('button')[0])
+ expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).toHaveClass('hidden')
+ fireEvent.click(screen.getAllByRole('button')[0])
+ expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).not.toHaveClass('hidden')
+ expect(mockNodePanel).toHaveBeenCalledWith(expect.objectContaining({
+ hideInfo: true,
+ hideProcessDetail: true,
+ }))
+
+ nestedParallelTarget.remove()
+ unrelatedTarget.remove()
+ })
+
+ it('should switch to the special result panel when the log state requests it', () => {
+ mockUseLogs.mockReturnValue({
+ showSpecialResultPanel: true,
+ showRetryDetail: true,
+ setShowRetryDetailFalse: vi.fn(),
+ retryResultList: [{ id: 'retry-1' }],
+ handleShowRetryResultList: vi.fn(),
+ showIteratingDetail: true,
+ setShowIteratingDetailFalse: vi.fn(),
+ iterationResultList: [[{ id: 'iter-1' }]],
+ iterationResultDurationMap: { 0: 1 },
+ handleShowIterationResultList: vi.fn(),
+ showLoopingDetail: true,
+ setShowLoopingDetailFalse: vi.fn(),
+ loopResultList: [[{ id: 'loop-1' }]],
+ loopResultDurationMap: { 0: 2 },
+ loopResultVariableMap: { 0: {} },
+ handleShowLoopResultList: vi.fn(),
+ agentOrToolLogItemStack: [{ id: 'agent-1' }],
+ agentOrToolLogListMap: { agent: [] },
+ handleShowAgentOrToolLog: vi.fn(),
+ })
+
+ render()
+
+ expect(screen.getByTestId('special-result-panel')).toBeInTheDocument()
+ expect(mockSpecialResultPanel).toHaveBeenCalledWith(expect.objectContaining({
+ showRetryDetail: true,
+ retryResultList: [{ id: 'retry-1' }],
+ showIteratingDetail: true,
+ showLoopingDetail: true,
+ agentOrToolLogItemStack: [{ id: 'agent-1' }],
+ }))
+ })
+
+ it('should resolve hovered parallel ids from related targets', () => {
+ const sameParallelTarget = document.createElement('div')
+ sameParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
+ document.body.appendChild(sameParallelTarget)
+
+ const nestedChild = document.createElement('span')
+ sameParallelTarget.appendChild(nestedChild)
+
+ const unrelatedTarget = document.createElement('div')
+
+ expect(getHoveredParallelId(nestedChild)).toBe('parallel-1')
+ expect(getHoveredParallelId(unrelatedTarget)).toBeNull()
+ expect(getHoveredParallelId(null)).toBeNull()
+
+ sameParallelTarget.remove()
+ })
+})
diff --git a/web/app/components/workflow/run/get-hovered-parallel-id.ts b/web/app/components/workflow/run/get-hovered-parallel-id.ts
new file mode 100644
index 0000000000..cd369d5eb1
--- /dev/null
+++ b/web/app/components/workflow/run/get-hovered-parallel-id.ts
@@ -0,0 +1,10 @@
+export const getHoveredParallelId = (relatedTarget: EventTarget | null) => {
+ const element = relatedTarget as Element | null
+ if (element && 'closest' in element) {
+ const closestParallel = element.closest('[data-parallel-id]')
+ if (closestParallel)
+ return closestParallel.getAttribute('data-parallel-id')
+ }
+
+ return null
+}
diff --git a/web/app/components/workflow/run/tracing-panel.tsx b/web/app/components/workflow/run/tracing-panel.tsx
index 8931c8f7fe..dba158f0b2 100644
--- a/web/app/components/workflow/run/tracing-panel.tsx
+++ b/web/app/components/workflow/run/tracing-panel.tsx
@@ -1,10 +1,6 @@
'use client'
import type { FC } from 'react'
import type { NodeTracing } from '@/types/workflow'
-import {
- RiArrowDownSLine,
- RiMenu4Line,
-} from '@remixicon/react'
import * as React from 'react'
import {
useCallback,
@@ -13,6 +9,7 @@ import {
import { useTranslation } from 'react-i18next'
import formatNodeList from '@/app/components/workflow/run/utils/format-log'
import { cn } from '@/utils/classnames'
+import { getHoveredParallelId } from './get-hovered-parallel-id'
import { useLogs } from './hooks'
import NodePanel from './node'
import SpecialResultPanel from './special-result-panel'
@@ -53,18 +50,7 @@ const TracingPanel: FC = ({
}, [])
const handleParallelMouseLeave = useCallback((e: React.MouseEvent) => {
- const relatedTarget = e.relatedTarget as Element | null
- if (relatedTarget && 'closest' in relatedTarget) {
- const closestParallel = relatedTarget.closest('[data-parallel-id]')
- if (closestParallel)
- setHoveredParallel(closestParallel.getAttribute('data-parallel-id'))
-
- else
- setHoveredParallel(null)
- }
- else {
- setHoveredParallel(null)
- }
+ setHoveredParallel(getHoveredParallelId(e.relatedTarget))
}, [])
const {
@@ -116,9 +102,11 @@ const TracingPanel: FC = ({
isHovered ? 'rounded border-components-button-primary-border bg-components-button-primary-bg text-text-primary-on-surface' : 'text-text-secondary hover:text-text-primary',
)}
>
- {isHovered ? : }
+ {isHovered
+ ?
+ : }
-
+
{parallelDetail.parallelTitle}
= ({
const isHovered = hoveredParallel === node.id
return (
-
+
{node?.parallelDetail?.branchTitle}
vi.fn())
+const mockFormatHumanInputNode = vi.hoisted(() => vi.fn())
+const mockFormatRetryNode = vi.hoisted(() => vi.fn())
+const mockAddChildrenToLoopNode = vi.hoisted(() => vi.fn())
+const mockAddChildrenToIterationNode = vi.hoisted(() => vi.fn())
+const mockFormatParallelNode = vi.hoisted(() => vi.fn())
+
+vi.mock('../agent', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatAgentNode(...args),
+}))
+
+vi.mock('../human-input', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatHumanInputNode(...args),
+}))
+
+vi.mock('../retry', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatRetryNode(...args),
+}))
+
+vi.mock('../loop', () => ({
+ addChildrenToLoopNode: (...args: unknown[]) => mockAddChildrenToLoopNode(...args),
+}))
+
+vi.mock('../iteration', () => ({
+ addChildrenToIterationNode: (...args: unknown[]) => mockAddChildrenToIterationNode(...args),
+}))
+
+vi.mock('../parallel', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatParallelNode(...args),
+}))
+
+const createTrace = (overrides: Partial = {}): NodeTracing => ({
+ id: overrides.id ?? overrides.node_id ?? 'node-1',
+ index: overrides.index ?? 0,
+ predecessor_node_id: '',
+ node_id: overrides.node_id ?? 'node-1',
+ node_type: overrides.node_type ?? BlockEnum.Tool,
+ title: overrides.title ?? 'Node',
+ inputs: {},
+ inputs_truncated: false,
+ process_data: {},
+ process_data_truncated: false,
+ outputs_truncated: false,
+ status: overrides.status ?? 'succeeded',
+ error: overrides.error,
+ elapsed_time: 1,
+ execution_metadata: overrides.execution_metadata ?? {
+ total_tokens: 0,
+ total_price: 0,
+ currency: 'USD',
+ },
+ metadata: {
+ iterator_length: 0,
+ iterator_index: 0,
+ loop_length: 0,
+ loop_index: 0,
+ },
+ created_at: 0,
+ created_by: {
+ id: 'user-1',
+ name: 'User',
+ email: 'user@example.com',
+ },
+ finished_at: 1,
+})
+
+const createExecutionMetadata = (overrides: Partial> = {}): NonNullable => ({
+ total_tokens: 0,
+ total_price: 0,
+ currency: 'USD',
+ ...overrides,
+})
+
+describe('formatToTracingNodeList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFormatAgentNode.mockImplementation((list: NodeTracing[]) => list)
+ mockFormatHumanInputNode.mockImplementation((list: NodeTracing[]) => list)
+ mockFormatRetryNode.mockImplementation((list: NodeTracing[]) => list)
+ mockAddChildrenToLoopNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
+ ...item,
+ loopChildren: children.map(child => child.node_id),
+ details: [[{ id: 'loop-detail-row' }]],
+ }))
+ mockAddChildrenToIterationNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
+ ...item,
+ iterationChildren: children.map(child => child.node_id),
+ details: [[{ id: 'iteration-detail-row' }]],
+ }))
+ mockFormatParallelNode.mockImplementation((list: unknown[]) =>
+ list.map(item => ({
+ ...(item as Record),
+ parallelFormatted: true,
+ })))
+ })
+
+ it('should sort the input by index and run the formatter pipeline in order', () => {
+ const t = vi.fn((key: string) => key)
+ const traces = [
+ createTrace({ id: 'b', node_id: 'b', title: 'B', index: 2 }),
+ createTrace({ id: 'a', node_id: 'a', title: 'A', index: 0 }),
+ createTrace({ id: 'c', node_id: 'c', title: 'C', index: 1 }),
+ ]
+
+ const result = formatToTracingNodeList(traces, t)
+
+ expect(mockFormatAgentNode).toHaveBeenCalledWith([
+ expect.objectContaining({ node_id: 'a' }),
+ expect.objectContaining({ node_id: 'c' }),
+ expect.objectContaining({ node_id: 'b' }),
+ ])
+ expect(mockFormatHumanInputNode).toHaveBeenCalledWith(mockFormatAgentNode.mock.results[0].value)
+ expect(mockFormatRetryNode).toHaveBeenCalledWith(mockFormatHumanInputNode.mock.results[0].value)
+ expect(mockFormatParallelNode).toHaveBeenLastCalledWith(expect.any(Array), t)
+ expect(result).toEqual([
+ expect.objectContaining({ node_id: 'a', parallelFormatted: true }),
+ expect.objectContaining({ node_id: 'c', parallelFormatted: true }),
+ expect.objectContaining({ node_id: 'b', parallelFormatted: true }),
+ ])
+ })
+
+ it('should collapse loop and iteration children into parent nodes and propagate child failures', () => {
+ const t = vi.fn((key: string) => key)
+ const loopParent = createTrace({
+ id: 'loop-parent',
+ node_id: 'loop-parent',
+ node_type: BlockEnum.Loop,
+ index: 0,
+ })
+ const loopChild = createTrace({
+ id: 'loop-child',
+ node_id: 'loop-child',
+ index: 1,
+ status: 'failed',
+ error: 'loop child failed',
+ execution_metadata: createExecutionMetadata({ loop_id: 'loop-parent' }),
+ })
+ const iterationParent = createTrace({
+ id: 'iteration-parent',
+ node_id: 'iteration-parent',
+ node_type: BlockEnum.Iteration,
+ index: 2,
+ })
+ const iterationChild = createTrace({
+ id: 'iteration-child',
+ node_id: 'iteration-child',
+ index: 3,
+ status: 'failed',
+ error: 'iteration child failed',
+ execution_metadata: createExecutionMetadata({ iteration_id: 'iteration-parent' }),
+ })
+
+ const result = formatToTracingNodeList([
+ loopParent,
+ loopChild,
+ iterationParent,
+ iterationChild,
+ ], t)
+
+ expect(mockAddChildrenToLoopNode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ node_id: 'loop-parent',
+ status: 'failed',
+ error: 'loop child failed',
+ }),
+ [expect.objectContaining({ node_id: 'loop-child' })],
+ )
+ expect(mockAddChildrenToIterationNode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ node_id: 'iteration-parent',
+ status: 'failed',
+ error: 'iteration child failed',
+ }),
+ [expect.objectContaining({ node_id: 'iteration-child' })],
+ )
+ expect(mockFormatParallelNode).toHaveBeenCalledTimes(3)
+ expect(result).toEqual([
+ expect.objectContaining({
+ node_id: 'loop-parent',
+ loopChildren: ['loop-child'],
+ parallelFormatted: true,
+ }),
+ expect.objectContaining({
+ node_id: 'iteration-parent',
+ iterationChildren: ['iteration-child'],
+ parallelFormatted: true,
+ }),
+ ])
+ })
+})