From 3d10cf97f188e64d0126147448430b06ce598789 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 25 Mar 2026 12:12:59 +0800 Subject: [PATCH] test: add unit tests for various workflow components - Introduced new test files for CandidateNodeMain, CustomEdge, NodeContextmenu, PanelContextmenu, HelpLine, and several hooks. - Each test file includes comprehensive tests to validate component rendering, interactions, and state management. - Enhanced test coverage for dynamic test run options and data source configurations. - Ensured proper mocking of dependencies to isolate component behavior during tests. --- .../__tests__/candidate-node-main.spec.tsx | 260 +++++++++++++ .../workflow/__tests__/custom-edge.spec.tsx | 235 ++++++++++++ .../__tests__/node-contextmenu.spec.tsx | 114 ++++++ .../__tests__/panel-contextmenu.spec.tsx | 151 ++++++++ .../help-line/__tests__/index.spec.tsx | 61 +++ .../hooks/__tests__/use-config-vision.spec.ts | 171 +++++++++ .../use-dynamic-test-run-options.spec.tsx | 146 +++++++ .../last-run/__tests__/index.spec.tsx | 235 ++++++++++++ .../hooks/__tests__/use-config.spec.ts | 139 +++++++ .../__tests__/button-style-dropdown.spec.tsx | 149 ++++++++ .../__tests__/form-content-preview.spec.tsx | 135 +++++++ .../__tests__/form-content.spec.tsx | 258 +++++++++++++ .../components/__tests__/timeout.spec.tsx | 77 ++++ .../components/__tests__/user-action.spec.tsx | 143 +++++++ .../delivery-method/__tests__/index.spec.tsx | 150 ++++++++ .../recipient/__tests__/index.spec.tsx | 156 ++++++++ .../hooks/__tests__/use-config.spec.ts | 156 ++++++++ .../hooks/__tests__/use-form-content.spec.ts | 112 ++++++ .../use-single-run-form-params.spec.ts | 234 ++++++++++++ .../iteration/__tests__/use-config.spec.ts | 173 +++++++++ .../use-single-run-form-params.spec.ts | 168 +++++++++ .../nodes/start/__tests__/use-config.spec.ts | 238 ++++++++++++ .../variable-assigner/__tests__/hooks.spec.ts | 244 ++++++++++++ .../workflow/run/__tests__/hooks.spec.ts | 127 +++++++ .../run/__tests__/result-panel.spec.tsx | 356 ++++++++++++++++++ .../run/__tests__/tracing-panel.spec.tsx | 199 ++++++++++ .../workflow/run/get-hovered-parallel-id.ts | 10 + .../components/workflow/run/tracing-panel.tsx | 26 +- .../utils/format-log/__tests__/index.spec.ts | 199 ++++++++++ 29 files changed, 4803 insertions(+), 19 deletions(-) create mode 100644 web/app/components/workflow/__tests__/candidate-node-main.spec.tsx create mode 100644 web/app/components/workflow/__tests__/custom-edge.spec.tsx create mode 100644 web/app/components/workflow/__tests__/node-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/help-line/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts create mode 100644 web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts create mode 100644 web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts create mode 100644 web/app/components/workflow/run/__tests__/hooks.spec.ts create mode 100644 web/app/components/workflow/run/__tests__/result-panel.spec.tsx create mode 100644 web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx create mode 100644 web/app/components/workflow/run/get-hovered-parallel-id.ts create mode 100644 web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts 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, + }), + ]) + }) +})