From 5385ec3023c46001ef1462a29ab3937d342256f2 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 4 Mar 2026 17:24:50 +0800 Subject: [PATCH] test(workflow): add comprehensive hooks unit tests and refactor test infrastructure (Part 3) (#32958) Co-authored-by: CodingOnStar --- .../components/workflow/__tests__/fixtures.ts | 73 ++- .../workflow/__tests__/mock-hooks-store.ts | 59 --- .../workflow/__tests__/mock-reactflow.ts | 110 ----- .../workflow/__tests__/mock-workflow-store.ts | 199 -------- .../__tests__/reactflow-mock-state.ts | 143 ++++++ .../__tests__/service-mock-factory.ts | 75 +++ ....test.tsx => trigger-status-sync.spec.tsx} | 6 +- .../workflow/__tests__/workflow-test-env.tsx | 195 ++++++++ .../use-auto-generate-webhook-url.spec.ts | 83 ++++ .../__tests__/use-available-blocks.spec.ts | 162 +++++++ .../hooks/__tests__/use-checklist.spec.ts | 312 +++++++++++++ .../__tests__/use-edges-interactions.spec.ts | 151 ++++++ .../hooks/__tests__/use-helpline.spec.ts | 194 ++++++++ .../__tests__/use-hooksstore-wrappers.spec.ts | 79 ++++ .../__tests__/use-node-data-update.spec.ts | 99 ++++ .../__tests__/use-nodes-sync-draft.spec.ts | 79 ++++ .../__tests__/use-panel-interactions.spec.ts | 78 ++++ .../use-selection-interactions.spec.ts | 190 ++++++++ .../use-serial-async-callback.spec.ts | 94 ++++ .../hooks/__tests__/use-tool-icon.spec.ts | 171 +++++++ .../__tests__/use-without-sync-hooks.spec.ts | 130 ++++++ .../hooks/__tests__/use-workflow-mode.spec.ts | 47 ++ .../use-workflow-run-event-store-only.spec.ts | 242 ++++++++++ .../use-workflow-run-event-with-store.spec.ts | 269 +++++++++++ ...e-workflow-run-event-with-viewport.spec.ts | 244 ++++++++++ .../__tests__/use-workflow-variables.spec.ts | 148 ++++++ .../hooks/__tests__/use-workflow.spec.ts | 234 ++++++++++ ...an-input.test.tsx => human-input.spec.tsx} | 0 ...ls.test.ts => output-schema-utils.spec.ts} | 2 +- ...m-helpers.test.ts => form-helpers.spec.ts} | 4 +- .../__tests__/chat-variable-slice.spec.ts | 4 +- .../__tests__/env-variable-slice.spec.ts | 4 +- .../__tests__/inspect-vars-slice.spec.ts | 4 +- ...-status.test.ts => trigger-status.spec.ts} | 0 .../store/__tests__/version-slice.spec.ts | 4 +- .../__tests__/workflow-draft-slice.spec.ts | 18 +- .../store/__tests__/workflow-store.spec.ts | 438 ++++-------------- 37 files changed, 3615 insertions(+), 729 deletions(-) delete mode 100644 web/app/components/workflow/__tests__/mock-hooks-store.ts delete mode 100644 web/app/components/workflow/__tests__/mock-reactflow.ts delete mode 100644 web/app/components/workflow/__tests__/mock-workflow-store.ts create mode 100644 web/app/components/workflow/__tests__/reactflow-mock-state.ts create mode 100644 web/app/components/workflow/__tests__/service-mock-factory.ts rename web/app/components/workflow/__tests__/{trigger-status-sync.test.tsx => trigger-status-sync.spec.tsx} (98%) create mode 100644 web/app/components/workflow/__tests__/workflow-test-env.tsx create mode 100644 web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-nodes-sync-draft.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-mode.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts rename web/app/components/workflow/nodes/human-input/__tests__/{human-input.test.tsx => human-input.spec.tsx} (100%) rename web/app/components/workflow/nodes/tool/__tests__/{output-schema-utils.test.ts => output-schema-utils.spec.ts} (99%) rename web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/{form-helpers.test.ts => form-helpers.spec.ts} (98%) rename web/app/components/workflow/store/__tests__/{trigger-status.test.ts => trigger-status.spec.ts} (100%) diff --git a/web/app/components/workflow/__tests__/fixtures.ts b/web/app/components/workflow/__tests__/fixtures.ts index 50a42ebe3d..ebc1d0d300 100644 --- a/web/app/components/workflow/__tests__/fixtures.ts +++ b/web/app/components/workflow/__tests__/fixtures.ts @@ -1,4 +1,5 @@ -import type { CommonEdgeType, CommonNodeType, Edge, Node } from '../types' +import type { CommonEdgeType, CommonNodeType, Edge, Node, ToolWithProvider, WorkflowRunningData } from '../types' +import type { NodeTracing } from '@/types/workflow' import { Position } from 'reactflow' import { CUSTOM_NODE } from '../constants' import { BlockEnum, NodeRunningStatus } from '../types' @@ -108,4 +109,74 @@ export function createLinearGraph(nodeCount: number): { nodes: Node[], edges: Ed return { nodes, edges } } +// --------------------------------------------------------------------------- +// Workflow-level factories +// --------------------------------------------------------------------------- + +export function createWorkflowRunningData( + overrides?: Partial, +): WorkflowRunningData { + return { + task_id: 'task-test', + result: { + status: 'running', + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + ...overrides?.result, + }, + tracing: overrides?.tracing ?? [], + ...overrides, + } +} + +export function createNodeTracing( + overrides?: Partial, +): NodeTracing { + const nodeId = overrides?.node_id ?? 'node-1' + return { + id: `trace-${nodeId}`, + index: 0, + predecessor_node_id: '', + node_id: nodeId, + node_type: BlockEnum.Code, + title: 'Node', + inputs: null, + inputs_truncated: false, + process_data: null, + process_data_truncated: false, + outputs_truncated: false, + status: NodeRunningStatus.Running, + 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: 'Test', email: 'test@test.com' }, + finished_at: 0, + ...overrides, + } +} + +export function createToolWithProvider( + overrides?: Partial, +): ToolWithProvider { + return { + id: 'tool-provider-1', + name: 'test-tool', + author: 'test', + description: { en_US: 'Test tool', zh_Hans: '测试工具' }, + icon: '/icon.svg', + icon_dark: '/icon-dark.svg', + label: { en_US: 'Test Tool', zh_Hans: '测试工具' }, + type: 'builtin', + team_credentials: {}, + is_team_authorization: false, + allow_delete: true, + labels: [], + tools: [], + meta: { version: '0.0.1' }, + plugin_id: 'plugin-1', + ...overrides, + } +} + export { BlockEnum, NodeRunningStatus } diff --git a/web/app/components/workflow/__tests__/mock-hooks-store.ts b/web/app/components/workflow/__tests__/mock-hooks-store.ts deleted file mode 100644 index 9363b31c35..0000000000 --- a/web/app/components/workflow/__tests__/mock-hooks-store.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { noop } from 'es-toolkit' - -/** - * Default hooks store state. - * All function fields default to noop / vi.fn() stubs. - * Use `createHooksStoreState(overrides)` to get a customised state object. - */ -export function createHooksStoreState(overrides: Record = {}) { - return { - refreshAll: noop, - - // draft sync - doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), - syncWorkflowDraftWhenPageClose: noop, - handleRefreshWorkflowDraft: noop, - handleBackupDraft: noop, - handleLoadBackupDraft: noop, - handleRestoreFromPublishedWorkflow: noop, - - // run - handleRun: noop, - handleStopRun: noop, - handleStartWorkflowRun: noop, - handleWorkflowStartRunInWorkflow: noop, - handleWorkflowStartRunInChatflow: noop, - handleWorkflowTriggerScheduleRunInWorkflow: noop, - handleWorkflowTriggerWebhookRunInWorkflow: noop, - handleWorkflowTriggerPluginRunInWorkflow: noop, - handleWorkflowRunAllTriggersInWorkflow: noop, - - // meta - availableNodesMetaData: undefined, - configsMap: undefined, - - // export / DSL - exportCheck: vi.fn().mockResolvedValue(undefined), - handleExportDSL: vi.fn().mockResolvedValue(undefined), - getWorkflowRunAndTraceUrl: vi.fn().mockReturnValue({ runUrl: '', traceUrl: '' }), - - // inspect vars - fetchInspectVars: vi.fn().mockResolvedValue(undefined), - hasNodeInspectVars: vi.fn().mockReturnValue(false), - hasSetInspectVar: vi.fn().mockReturnValue(false), - fetchInspectVarValue: vi.fn().mockResolvedValue(undefined), - editInspectVarValue: vi.fn().mockResolvedValue(undefined), - renameInspectVarName: vi.fn().mockResolvedValue(undefined), - appendNodeInspectVars: noop, - deleteInspectVar: vi.fn().mockResolvedValue(undefined), - deleteNodeInspectorVars: vi.fn().mockResolvedValue(undefined), - deleteAllInspectorVars: vi.fn().mockResolvedValue(undefined), - isInspectVarEdited: vi.fn().mockReturnValue(false), - resetToLastRunVar: vi.fn().mockResolvedValue(undefined), - invalidateSysVarValues: noop, - resetConversationVar: vi.fn().mockResolvedValue(undefined), - invalidateConversationVarValues: noop, - - ...overrides, - } -} diff --git a/web/app/components/workflow/__tests__/mock-reactflow.ts b/web/app/components/workflow/__tests__/mock-reactflow.ts deleted file mode 100644 index 168713de4c..0000000000 --- a/web/app/components/workflow/__tests__/mock-reactflow.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * ReactFlow mock factory for workflow tests. - * - * Usage — add this to the top of any test file that imports reactflow: - * - * vi.mock('reactflow', async () => (await import('../__tests__/mock-reactflow')).createReactFlowMock()) - * - * Or for more control: - * - * vi.mock('reactflow', async () => { - * const base = (await import('../__tests__/mock-reactflow')).createReactFlowMock() - * return { ...base, useReactFlow: () => ({ ...base.useReactFlow(), fitView: vi.fn() }) } - * }) - */ -import * as React from 'react' - -export function createReactFlowMock(overrides: Record = {}) { - const noopComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) => - React.createElement('div', { 'data-testid': 'reactflow-mock' }, children) - noopComponent.displayName = 'ReactFlowMock' - - const backgroundComponent: React.FC = () => null - backgroundComponent.displayName = 'BackgroundMock' - - return { - // re-export the real Position enum - Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' }, - MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' }, - ConnectionMode: { Strict: 'strict', Loose: 'loose' }, - ConnectionLineType: { Bezier: 'default', Straight: 'straight', Step: 'step', SmoothStep: 'smoothstep' }, - - // components - default: noopComponent, - ReactFlow: noopComponent, - ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => - React.createElement(React.Fragment, null, children), - Background: backgroundComponent, - MiniMap: backgroundComponent, - Controls: backgroundComponent, - Handle: (props: Record) => React.createElement('div', { 'data-testid': 'handle', ...props }), - BaseEdge: (props: Record) => React.createElement('path', props), - EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) => - React.createElement('div', null, children), - - // hooks - useReactFlow: () => ({ - setCenter: vi.fn(), - fitView: vi.fn(), - zoomIn: vi.fn(), - zoomOut: vi.fn(), - zoomTo: vi.fn(), - getNodes: vi.fn().mockReturnValue([]), - getEdges: vi.fn().mockReturnValue([]), - getNode: vi.fn(), - setNodes: vi.fn(), - setEdges: vi.fn(), - addNodes: vi.fn(), - addEdges: vi.fn(), - deleteElements: vi.fn(), - getViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }), - setViewport: vi.fn(), - screenToFlowPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos), - flowToScreenPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos), - toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }), - viewportInitialized: true, - }), - - useStoreApi: () => ({ - getState: vi.fn().mockReturnValue({ - nodeInternals: new Map(), - edges: [], - transform: [0, 0, 1], - d3Selection: null, - d3Zoom: null, - }), - setState: vi.fn(), - subscribe: vi.fn().mockReturnValue(vi.fn()), - }), - - useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]), - - useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]), - - useStore: vi.fn().mockReturnValue(null), - useNodes: vi.fn().mockReturnValue([]), - useEdges: vi.fn().mockReturnValue([]), - useViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }), - useOnSelectionChange: vi.fn(), - useKeyPress: vi.fn().mockReturnValue(false), - useUpdateNodeInternals: vi.fn().mockReturnValue(vi.fn()), - useOnViewportChange: vi.fn(), - useNodeId: vi.fn().mockReturnValue(null), - - // utils - getOutgoers: vi.fn().mockReturnValue([]), - getIncomers: vi.fn().mockReturnValue([]), - getConnectedEdges: vi.fn().mockReturnValue([]), - isNode: vi.fn().mockReturnValue(true), - isEdge: vi.fn().mockReturnValue(false), - addEdge: vi.fn().mockImplementation((_edge: unknown, edges: unknown[]) => edges), - applyNodeChanges: vi.fn().mockImplementation((_changes: unknown[], nodes: unknown[]) => nodes), - applyEdgeChanges: vi.fn().mockImplementation((_changes: unknown[], edges: unknown[]) => edges), - getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), - getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), - getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), - internalsSymbol: Symbol('internals'), - - ...overrides, - } -} diff --git a/web/app/components/workflow/__tests__/mock-workflow-store.ts b/web/app/components/workflow/__tests__/mock-workflow-store.ts deleted file mode 100644 index 112384c4f6..0000000000 --- a/web/app/components/workflow/__tests__/mock-workflow-store.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { ControlMode, Node } from '../types' -import { noop } from 'es-toolkit' -import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../constants' - -/** - * Default workflow store state covering all slices. - * Use `createWorkflowStoreState(overrides)` to get a state object - * that can be injected via `useWorkflowStore.setState(...)` or - * used as the return value of a mocked `useStore` selector. - */ -export function createWorkflowStoreState(overrides: Record = {}) { - return { - // --- workflow-slice --- - workflowRunningData: undefined, - isListening: false, - listeningTriggerType: null, - listeningTriggerNodeId: null, - listeningTriggerNodeIds: [], - listeningTriggerIsAll: false, - clipboardElements: [] as Node[], - selection: null, - bundleNodeSize: null, - controlMode: 'pointer' as ControlMode, - mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 }, - showConfirm: undefined, - controlPromptEditorRerenderKey: 0, - showImportDSLModal: false, - fileUploadConfig: undefined, - - // --- node-slice --- - showSingleRunPanel: false, - nodeAnimation: false, - candidateNode: undefined, - nodeMenu: undefined, - showAssignVariablePopup: undefined, - hoveringAssignVariableGroupId: undefined, - connectingNodePayload: undefined, - enteringNodePayload: undefined, - iterTimes: DEFAULT_ITER_TIMES, - loopTimes: DEFAULT_LOOP_TIMES, - iterParallelLogMap: new Map(), - pendingSingleRun: undefined, - - // --- panel-slice --- - panelWidth: 420, - showFeaturesPanel: false, - showWorkflowVersionHistoryPanel: false, - showInputsPanel: false, - showDebugAndPreviewPanel: false, - panelMenu: undefined, - selectionMenu: undefined, - showVariableInspectPanel: false, - initShowLastRunTab: false, - - // --- help-line-slice --- - helpLineHorizontal: undefined, - helpLineVertical: undefined, - - // --- history-slice --- - historyWorkflowData: undefined, - showRunHistory: false, - versionHistory: [], - - // --- chat-variable-slice --- - showChatVariablePanel: false, - showGlobalVariablePanel: false, - conversationVariables: [], - - // --- env-variable-slice --- - showEnvPanel: false, - environmentVariables: [], - envSecrets: {}, - - // --- form-slice --- - inputs: {}, - files: [], - - // --- tool-slice --- - toolPublished: false, - lastPublishedHasUserInput: false, - buildInTools: undefined, - customTools: undefined, - workflowTools: undefined, - mcpTools: undefined, - - // --- version-slice --- - draftUpdatedAt: 0, - publishedAt: 0, - currentVersion: null, - isRestoring: false, - - // --- workflow-draft-slice --- - backupDraft: undefined, - syncWorkflowDraftHash: '', - isSyncingWorkflowDraft: false, - isWorkflowDataLoaded: false, - nodes: [] as Node[], - - // --- inspect-vars-slice --- - currentFocusNodeId: null, - nodesWithInspectVars: [], - conversationVars: [], - - // --- layout-slice --- - workflowCanvasWidth: undefined, - workflowCanvasHeight: undefined, - rightPanelWidth: undefined, - nodePanelWidth: 420, - previewPanelWidth: 420, - otherPanelWidth: 420, - bottomPanelWidth: 0, - bottomPanelHeight: 0, - variableInspectPanelHeight: 300, - maximizeCanvas: false, - - // --- setters (all default to noop, override as needed) --- - setWorkflowRunningData: noop, - setIsListening: noop, - setListeningTriggerType: noop, - setListeningTriggerNodeId: noop, - setListeningTriggerNodeIds: noop, - setListeningTriggerIsAll: noop, - setClipboardElements: noop, - setSelection: noop, - setBundleNodeSize: noop, - setControlMode: noop, - setMousePosition: noop, - setShowConfirm: noop, - setControlPromptEditorRerenderKey: noop, - setShowImportDSLModal: noop, - setFileUploadConfig: noop, - setShowSingleRunPanel: noop, - setNodeAnimation: noop, - setCandidateNode: noop, - setNodeMenu: noop, - setShowAssignVariablePopup: noop, - setHoveringAssignVariableGroupId: noop, - setConnectingNodePayload: noop, - setEnteringNodePayload: noop, - setIterTimes: noop, - setLoopTimes: noop, - setIterParallelLogMap: noop, - setPendingSingleRun: noop, - setShowFeaturesPanel: noop, - setShowWorkflowVersionHistoryPanel: noop, - setShowInputsPanel: noop, - setShowDebugAndPreviewPanel: noop, - setPanelMenu: noop, - setSelectionMenu: noop, - setShowVariableInspectPanel: noop, - setInitShowLastRunTab: noop, - setHelpLineHorizontal: noop, - setHelpLineVertical: noop, - setHistoryWorkflowData: noop, - setShowRunHistory: noop, - setVersionHistory: noop, - setShowChatVariablePanel: noop, - setShowGlobalVariablePanel: noop, - setConversationVariables: noop, - setShowEnvPanel: noop, - setEnvironmentVariables: noop, - setEnvSecrets: noop, - setInputs: noop, - setFiles: noop, - setToolPublished: noop, - setLastPublishedHasUserInput: noop, - setDraftUpdatedAt: noop, - setPublishedAt: noop, - setCurrentVersion: noop, - setIsRestoring: noop, - setBackupDraft: noop, - setSyncWorkflowDraftHash: noop, - setIsSyncingWorkflowDraft: noop, - setIsWorkflowDataLoaded: noop, - setNodes: noop, - flushPendingSync: noop, - setCurrentFocusNodeId: noop, - setNodesWithInspectVars: noop, - setNodeInspectVars: noop, - deleteAllInspectVars: noop, - deleteNodeInspectVars: noop, - setInspectVarValue: noop, - resetToLastRunVar: noop, - renameInspectVarName: noop, - deleteInspectVar: noop, - setWorkflowCanvasWidth: noop, - setWorkflowCanvasHeight: noop, - setRightPanelWidth: noop, - setNodePanelWidth: noop, - setPreviewPanelWidth: noop, - setOtherPanelWidth: noop, - setBottomPanelWidth: noop, - setBottomPanelHeight: noop, - setVariableInspectPanelHeight: noop, - setMaximizeCanvas: noop, - - ...overrides, - } -} diff --git a/web/app/components/workflow/__tests__/reactflow-mock-state.ts b/web/app/components/workflow/__tests__/reactflow-mock-state.ts new file mode 100644 index 0000000000..dd7a73d2a9 --- /dev/null +++ b/web/app/components/workflow/__tests__/reactflow-mock-state.ts @@ -0,0 +1,143 @@ +/** + * Shared mutable ReactFlow mock state for hook/component tests. + * + * Mutate `rfState` in `beforeEach` to configure nodes/edges, + * then assert on `rfState.setNodes`, `rfState.setEdges`, etc. + * + * Usage (one line at top of test file): + * ```ts + * vi.mock('reactflow', async () => + * (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), + * ) + * ``` + */ +import * as React from 'react' + +type MockNode = { + id: string + position: { x: number, y: number } + width?: number + height?: number + parentId?: string + data: Record +} + +type MockEdge = { + id: string + source: string + target: string + sourceHandle?: string + data: Record +} + +type ReactFlowMockState = { + nodes: MockNode[] + edges: MockEdge[] + transform: [number, number, number] + setViewport: ReturnType + setNodes: ReturnType + setEdges: ReturnType +} + +export const rfState: ReactFlowMockState = { + nodes: [], + edges: [], + transform: [0, 0, 1], + setViewport: vi.fn(), + setNodes: vi.fn(), + setEdges: vi.fn(), +} + +export function resetReactFlowMockState() { + rfState.nodes = [] + rfState.edges = [] + rfState.transform = [0, 0, 1] + rfState.setViewport.mockReset() + rfState.setNodes.mockReset() + rfState.setEdges.mockReset() +} + +export function createReactFlowModuleMock() { + return { + Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' }, + MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' }, + ConnectionMode: { Strict: 'strict', Loose: 'loose' }, + + useStoreApi: vi.fn(() => ({ + getState: () => ({ + getNodes: () => rfState.nodes, + setNodes: rfState.setNodes, + edges: rfState.edges, + setEdges: rfState.setEdges, + transform: rfState.transform, + nodeInternals: new Map(), + d3Selection: null, + d3Zoom: null, + }), + setState: vi.fn(), + subscribe: vi.fn().mockReturnValue(vi.fn()), + })), + + useReactFlow: vi.fn(() => ({ + setViewport: rfState.setViewport, + setCenter: vi.fn(), + fitView: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + zoomTo: vi.fn(), + getNodes: () => rfState.nodes, + getEdges: () => rfState.edges, + setNodes: rfState.setNodes, + setEdges: rfState.setEdges, + getViewport: () => ({ x: 0, y: 0, zoom: 1 }), + screenToFlowPosition: (pos: { x: number, y: number }) => pos, + flowToScreenPosition: (pos: { x: number, y: number }) => pos, + deleteElements: vi.fn(), + addNodes: vi.fn(), + addEdges: vi.fn(), + getNode: vi.fn(), + toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }), + viewportInitialized: true, + })), + + useStore: vi.fn().mockReturnValue(null), + useNodes: vi.fn(() => rfState.nodes), + useEdges: vi.fn(() => rfState.edges), + useViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })), + useKeyPress: vi.fn(() => false), + useOnSelectionChange: vi.fn(), + useOnViewportChange: vi.fn(), + useUpdateNodeInternals: vi.fn(() => vi.fn()), + useNodeId: vi.fn(() => null), + + useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]), + useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]), + + ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + ReactFlow: ({ children }: { children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'reactflow-mock' }, children), + Background: () => null, + MiniMap: () => null, + Controls: () => null, + Handle: (props: Record) => React.createElement('div', props), + BaseEdge: (props: Record) => React.createElement('path', props), + EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) => + React.createElement('div', null, children), + + getOutgoers: vi.fn().mockReturnValue([]), + getIncomers: vi.fn().mockReturnValue([]), + getConnectedEdges: vi.fn().mockReturnValue([]), + isNode: vi.fn().mockReturnValue(true), + isEdge: vi.fn().mockReturnValue(false), + addEdge: vi.fn().mockImplementation((_e: unknown, edges: unknown[]) => edges), + applyNodeChanges: vi.fn().mockImplementation((_c: unknown[], nodes: unknown[]) => nodes), + applyEdgeChanges: vi.fn().mockImplementation((_c: unknown[], edges: unknown[]) => edges), + getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), + getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), + getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), + internalsSymbol: Symbol('internals'), + } +} + +export type { MockEdge, MockNode, ReactFlowMockState } diff --git a/web/app/components/workflow/__tests__/service-mock-factory.ts b/web/app/components/workflow/__tests__/service-mock-factory.ts new file mode 100644 index 0000000000..7998c15481 --- /dev/null +++ b/web/app/components/workflow/__tests__/service-mock-factory.ts @@ -0,0 +1,75 @@ +/** + * Centralized mock factories for external services used by workflow. + * + * Usage: + * ```ts + * vi.mock('@/service/use-tools', async () => + * (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), + * ) + * vi.mock('@/app/components/app/store', async () => + * (await import('../../__tests__/service-mock-factory')).createAppStoreMock(), + * ) + * ``` + */ + +// --------------------------------------------------------------------------- +// App store +// --------------------------------------------------------------------------- + +type AppStoreMockData = { + appId?: string + appMode?: string +} + +export function createAppStoreMock(data?: AppStoreMockData) { + return { + useStore: { + getState: () => ({ + appDetail: { + id: data?.appId ?? 'app-test-id', + mode: data?.appMode ?? 'workflow', + }, + }), + }, + } +} + +// --------------------------------------------------------------------------- +// SWR service hooks +// --------------------------------------------------------------------------- + +type ToolMockData = { + buildInTools?: unknown[] + customTools?: unknown[] + workflowTools?: unknown[] + mcpTools?: unknown[] +} + +type TriggerMockData = { + triggerPlugins?: unknown[] +} + +type StrategyMockData = { + strategyProviders?: unknown[] +} + +export function createToolServiceMock(data?: ToolMockData) { + return { + useAllBuiltInTools: vi.fn(() => ({ data: data?.buildInTools ?? [] })), + useAllCustomTools: vi.fn(() => ({ data: data?.customTools ?? [] })), + useAllWorkflowTools: vi.fn(() => ({ data: data?.workflowTools ?? [] })), + useAllMCPTools: vi.fn(() => ({ data: data?.mcpTools ?? [] })), + } +} + +export function createTriggerServiceMock(data?: TriggerMockData) { + return { + useAllTriggerPlugins: vi.fn(() => ({ data: data?.triggerPlugins ?? [] })), + } +} + +export function createStrategyServiceMock(data?: StrategyMockData) { + return { + useStrategyProviders: vi.fn(() => ({ data: data?.strategyProviders ?? [] })), + } +} diff --git a/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx b/web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx similarity index 98% rename from web/app/components/workflow/__tests__/trigger-status-sync.test.tsx rename to web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx index d3c3d235fe..76be431aa7 100644 --- a/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx +++ b/web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx @@ -276,7 +276,7 @@ describe('Trigger Status Synchronization Integration', () => { nodeId: string nodeType: string }> = ({ nodeId, nodeType }) => { - const triggerStatusSelector = useCallback((state: any) => + const triggerStatusSelector = useCallback((state: { triggerStatuses: Record }) => mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', [nodeId, nodeType]) const triggerStatus = useTriggerStatusStore(triggerStatusSelector) @@ -319,9 +319,9 @@ describe('Trigger Status Synchronization Integration', () => { const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => { const triggerStatusSelector = useCallback( - (state: any) => + (state: { triggerStatuses: Record }) => mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled', - ['test-node', nodeType], // Dependencies should match implementation + [nodeType], ) const status = useTriggerStatusStore(triggerStatusSelector) return
diff --git a/web/app/components/workflow/__tests__/workflow-test-env.tsx b/web/app/components/workflow/__tests__/workflow-test-env.tsx new file mode 100644 index 0000000000..6109d8a7f4 --- /dev/null +++ b/web/app/components/workflow/__tests__/workflow-test-env.tsx @@ -0,0 +1,195 @@ +/** + * Workflow test environment — composable providers + render helpers. + * + * ## Quick start + * + * ```ts + * import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' + * import { renderWorkflowHook } from '../../__tests__/workflow-test-env' + * + * // Mock ReactFlow (one line, only needed when the hook imports reactflow) + * vi.mock('reactflow', async () => + * (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), + * ) + * + * it('example', () => { + * resetReactFlowMockState() + * rfState.nodes = [{ id: 'n1', position: { x: 0, y: 0 }, data: {} }] + * + * const { result, store } = renderWorkflowHook( + * () => useMyHook(), + * { + * initialStoreState: { workflowRunningData: {...} }, + * hooksStoreProps: { doSyncWorkflowDraft: vi.fn() }, + * }, + * ) + * + * result.current.doSomething() + * expect(store.getState().someValue).toBe(expected) + * expect(rfState.setNodes).toHaveBeenCalled() + * }) + * ``` + */ +import type { RenderHookOptions, RenderHookResult } from '@testing-library/react' +import type { Shape as HooksStoreShape } from '../hooks-store/store' +import type { Shape } from '../store/workflow' +import type { Edge, Node, WorkflowRunningData } from '../types' +import type { WorkflowHistoryStoreApi } from '../workflow-history-store' +import { renderHook } from '@testing-library/react' +import isDeepEqual from 'fast-deep-equal' +import * as React from 'react' +import { temporal } from 'zundo' +import { create } from 'zustand' +import { WorkflowContext } from '../context' +import { HooksStoreContext } from '../hooks-store/provider' +import { createHooksStore } from '../hooks-store/store' +import { createWorkflowStore } from '../store/workflow' +import { WorkflowRunningStatus } from '../types' +import { WorkflowHistoryStoreContext } from '../workflow-history-store' + +// Re-exports are in a separate non-JSX file to avoid react-refresh warnings. +// Import directly from the individual modules: +// reactflow-mock-state.ts → rfState, resetReactFlowMockState, createReactFlowModuleMock +// service-mock-factory.ts → createToolServiceMock, createTriggerServiceMock, ... +// fixtures.ts → createNode, createEdge, createLinearGraph, ... + +// --------------------------------------------------------------------------- +// Test data factories +// --------------------------------------------------------------------------- + +export function baseRunningData(overrides: Record = {}) { + return { + task_id: 'task-1', + result: { status: WorkflowRunningStatus.Running } as WorkflowRunningData['result'], + tracing: [], + resultText: '', + resultTabActive: false, + ...overrides, + } as WorkflowRunningData +} + +// --------------------------------------------------------------------------- +// Store creation helpers +// --------------------------------------------------------------------------- + +type WorkflowStore = ReturnType +type HooksStore = ReturnType + +export function createTestWorkflowStore(initialState?: Partial): WorkflowStore { + const store = createWorkflowStore({}) + if (initialState) + store.setState(initialState) + return store +} + +export function createTestHooksStore(props?: Partial): HooksStore { + return createHooksStore(props ?? {}) +} + +// --------------------------------------------------------------------------- +// renderWorkflowHook — composable hook renderer +// --------------------------------------------------------------------------- + +type HistoryStoreConfig = { + nodes?: Node[] + edges?: Edge[] +} + +type WorkflowTestOptions

= Omit, 'wrapper'> & { + initialStoreState?: Partial + hooksStoreProps?: Partial + historyStore?: HistoryStoreConfig +} + +type WorkflowTestResult = RenderHookResult & { + store: WorkflowStore + hooksStore?: HooksStore +} + +/** + * Renders a hook inside composable workflow providers. + * + * Contexts provided based on options: + * - **Always**: `WorkflowContext` (real zustand store) + * - **hooksStoreProps**: `HooksStoreContext` (real zustand store) + * - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store) + */ +export function renderWorkflowHook( + hook: (props: P) => R, + options?: WorkflowTestOptions

, +): WorkflowTestResult { + const { initialStoreState, hooksStoreProps, historyStore: historyConfig, ...rest } = options ?? {} + + const store = createTestWorkflowStore(initialStoreState) + const hooksStore = hooksStoreProps !== undefined + ? createTestHooksStore(hooksStoreProps) + : undefined + + const wrapper = ({ children }: { children: React.ReactNode }) => { + let inner: React.ReactNode = children + + if (historyConfig) { + const historyCtxValue = createTestHistoryStoreContext(historyConfig) + inner = React.createElement( + WorkflowHistoryStoreContext.Provider, + { value: historyCtxValue }, + inner, + ) + } + + if (hooksStore) { + inner = React.createElement( + HooksStoreContext.Provider, + { value: hooksStore }, + inner, + ) + } + + return React.createElement( + WorkflowContext.Provider, + { value: store }, + inner, + ) + } + + const renderResult = renderHook(hook, { wrapper, ...rest }) + return { ...renderResult, store, hooksStore } +} + +// --------------------------------------------------------------------------- +// WorkflowHistoryStore test helper +// --------------------------------------------------------------------------- + +function createTestHistoryStoreContext(config: HistoryStoreConfig) { + const nodes = config.nodes ?? [] + const edges = config.edges ?? [] + + type HistState = { + workflowHistoryEvent: string | undefined + workflowHistoryEventMeta: unknown + nodes: Node[] + edges: Edge[] + getNodes: () => Node[] + setNodes: (n: Node[]) => void + setEdges: (e: Edge[]) => void + } + + const store = create(temporal( + (set, get) => ({ + workflowHistoryEvent: undefined, + workflowHistoryEventMeta: undefined, + nodes, + edges, + getNodes: () => get().nodes, + setNodes: (n: Node[]) => set({ nodes: n }), + setEdges: (e: Edge[]) => set({ edges: e }), + }), + { equality: (a, b) => isDeepEqual(a, b) }, + )) as unknown as WorkflowHistoryStoreApi + + return { + store, + shortcutsEnabled: true, + setShortcutsEnabled: () => {}, + } +} diff --git a/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts b/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts new file mode 100644 index 0000000000..cad77c3af8 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { BlockEnum } from '../../types' +import { useAutoGenerateWebhookUrl } from '../use-auto-generate-webhook-url' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +vi.mock('@/app/components/app/store', async () => + (await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' })) + +const mockFetchWebhookUrl = vi.fn() +vi.mock('@/service/apps', () => ({ + fetchWebhookUrl: (...args: unknown[]) => mockFetchWebhookUrl(...args), +})) + +describe('useAutoGenerateWebhookUrl', () => { + beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + rfState.nodes = [ + { id: 'webhook-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.TriggerWebhook, webhook_url: '' } }, + { id: 'code-1', position: { x: 300, y: 0 }, data: { type: BlockEnum.Code } }, + ] + }) + + it('should fetch and set webhook URL for a webhook trigger node', async () => { + mockFetchWebhookUrl.mockResolvedValue({ + webhook_url: 'https://example.com/webhook', + webhook_debug_url: 'https://example.com/webhook-debug', + }) + + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('webhook-1') + + expect(mockFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-123', nodeId: 'webhook-1' }) + expect(rfState.setNodes).toHaveBeenCalledOnce() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + const webhookNode = updatedNodes.find((n: { id: string }) => n.id === 'webhook-1') + expect(webhookNode.data.webhook_url).toBe('https://example.com/webhook') + expect(webhookNode.data.webhook_debug_url).toBe('https://example.com/webhook-debug') + }) + + it('should not fetch when node is not a webhook trigger', async () => { + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('code-1') + + expect(mockFetchWebhookUrl).not.toHaveBeenCalled() + expect(rfState.setNodes).not.toHaveBeenCalled() + }) + + it('should not fetch when node does not exist', async () => { + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('nonexistent') + + expect(mockFetchWebhookUrl).not.toHaveBeenCalled() + }) + + it('should not fetch when webhook_url already exists', async () => { + rfState.nodes[0].data.webhook_url = 'https://existing.com/webhook' + + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('webhook-1') + + expect(mockFetchWebhookUrl).not.toHaveBeenCalled() + }) + + it('should handle API errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockFetchWebhookUrl.mockRejectedValue(new Error('network error')) + + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('webhook-1') + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to auto-generate webhook URL:', + expect.any(Error), + ) + expect(rfState.setNodes).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts new file mode 100644 index 0000000000..c89ba9ce96 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts @@ -0,0 +1,162 @@ +import type { NodeDefault } from '../../types' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockClassificationEnum } from '../../block-selector/types' +import { BlockEnum } from '../../types' +import { useAvailableBlocks } from '../use-available-blocks' + +// Transitive imports of use-nodes-meta-data.ts — only useNodeMetaData uses these +vi.mock('@/service/use-tools', async () => + (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) +vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en' })) + +const mockNodeTypes = [ + BlockEnum.Start, + BlockEnum.End, + BlockEnum.LLM, + BlockEnum.Code, + BlockEnum.IfElse, + BlockEnum.Iteration, + BlockEnum.Loop, + BlockEnum.Tool, + BlockEnum.DataSource, + BlockEnum.KnowledgeBase, + BlockEnum.HumanInput, + BlockEnum.LoopEnd, +] + +function createNodeDefault(type: BlockEnum): NodeDefault { + return { + metaData: { + classification: BlockClassificationEnum.Default, + sort: 0, + type, + title: type, + author: 'test', + }, + defaultValue: {}, + checkValid: () => ({ isValid: true }), + } +} + +const hooksStoreProps = { + availableNodesMetaData: { + nodes: mockNodeTypes.map(createNodeDefault), + }, +} + +describe('useAvailableBlocks', () => { + describe('availablePrevBlocks', () => { + it('should return empty array when nodeType is undefined', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { hooksStoreProps }) + expect(result.current.availablePrevBlocks).toEqual([]) + }) + + it('should return empty array for Start node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.Start), { hooksStoreProps }) + expect(result.current.availablePrevBlocks).toEqual([]) + }) + + it('should return empty array for trigger nodes', () => { + for (const trigger of [BlockEnum.TriggerPlugin, BlockEnum.TriggerWebhook, BlockEnum.TriggerSchedule]) { + const { result } = renderWorkflowHook(() => useAvailableBlocks(trigger), { hooksStoreProps }) + expect(result.current.availablePrevBlocks).toEqual([]) + } + }) + + it('should return empty array for DataSource node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.DataSource), { hooksStoreProps }) + expect(result.current.availablePrevBlocks).toEqual([]) + }) + + it('should return all available nodes for regular block types', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + expect(result.current.availablePrevBlocks.length).toBeGreaterThan(0) + expect(result.current.availablePrevBlocks).toContain(BlockEnum.Code) + }) + }) + + describe('availableNextBlocks', () => { + it('should return empty array when nodeType is undefined', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { hooksStoreProps }) + expect(result.current.availableNextBlocks).toEqual([]) + }) + + it('should return empty array for End node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.End), { hooksStoreProps }) + expect(result.current.availableNextBlocks).toEqual([]) + }) + + it('should return empty array for LoopEnd node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LoopEnd), { hooksStoreProps }) + expect(result.current.availableNextBlocks).toEqual([]) + }) + + it('should return empty array for KnowledgeBase node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.KnowledgeBase), { hooksStoreProps }) + expect(result.current.availableNextBlocks).toEqual([]) + }) + + it('should return all available nodes for regular block types', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + expect(result.current.availableNextBlocks.length).toBeGreaterThan(0) + }) + }) + + describe('inContainer filtering', () => { + it('should exclude Iteration, Loop, End, DataSource, KnowledgeBase, HumanInput when inContainer=true', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, true), { hooksStoreProps }) + + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Iteration) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Loop) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.End) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.DataSource) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.KnowledgeBase) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.HumanInput) + }) + + it('should exclude LoopEnd when not in container', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, false), { hooksStoreProps }) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.LoopEnd) + }) + }) + + describe('getAvailableBlocks callback', () => { + it('should return prev and next blocks for a given node type', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const blocks = result.current.getAvailableBlocks(BlockEnum.Code) + + expect(blocks.availablePrevBlocks.length).toBeGreaterThan(0) + expect(blocks.availableNextBlocks.length).toBeGreaterThan(0) + }) + + it('should return empty prevBlocks for Start node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const blocks = result.current.getAvailableBlocks(BlockEnum.Start) + + expect(blocks.availablePrevBlocks).toEqual([]) + }) + + it('should return empty prevBlocks for DataSource node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const blocks = result.current.getAvailableBlocks(BlockEnum.DataSource) + + expect(blocks.availablePrevBlocks).toEqual([]) + }) + + it('should return empty nextBlocks for End/LoopEnd/KnowledgeBase', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + + expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks).toEqual([]) + expect(result.current.getAvailableBlocks(BlockEnum.LoopEnd).availableNextBlocks).toEqual([]) + expect(result.current.getAvailableBlocks(BlockEnum.KnowledgeBase).availableNextBlocks).toEqual([]) + }) + + it('should filter by inContainer when provided', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const blocks = result.current.getAvailableBlocks(BlockEnum.Code, true) + + expect(blocks.availableNextBlocks).not.toContain(BlockEnum.Iteration) + expect(blocks.availableNextBlocks).not.toContain(BlockEnum.Loop) + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts new file mode 100644 index 0000000000..d72d001e0b --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -0,0 +1,312 @@ +import type { CommonNodeType, Node } from '../../types' +import type { ChecklistItem } from '../use-checklist' +import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import { useChecklist, useWorkflowRunValidation } from '../use-checklist' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('reactflow', async () => { + const base = (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock() + return { + ...base, + getOutgoers: vi.fn((node: Node, nodes: Node[], edges: { source: string, target: string }[]) => { + return edges + .filter(e => e.source === node.id) + .map(e => nodes.find(n => n.id === e.target)) + .filter(Boolean) + }), + } +}) + +vi.mock('@/service/use-tools', async () => + (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) + +vi.mock('@/service/use-triggers', async () => + (await import('../../__tests__/service-mock-factory')).createTriggerServiceMock()) + +vi.mock('@/service/use-strategy', () => ({ + useStrategyProviders: () => ({ data: [] }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: () => ({ data: [] }), +})) + +type CheckValidFn = (data: CommonNodeType, t: unknown, extra?: unknown) => { errorMessage: string } +const mockNodesMap: Record = {} + +vi.mock('../use-nodes-meta-data', () => ({ + useNodesMetaData: () => ({ + nodes: [], + nodesMap: mockNodesMap, + }), +})) + +vi.mock('../use-nodes-available-var-list', () => ({ + default: (nodes: Node[]) => { + const map: Record = {} + if (nodes) { + for (const n of nodes) + map[n.id] = { availableVars: [] } + } + return map + }, + useGetNodesAvailableVarList: () => ({ getNodesAvailableVarList: vi.fn(() => ({})) }), +})) + +vi.mock('../../nodes/_base/components/variable/utils', () => ({ + getNodeUsedVars: () => [], + isSpecialVar: () => false, +})) + +vi.mock('@/app/components/app/store', () => { + const state = { appDetail: { mode: 'workflow' } } + return { + useStore: { + getState: () => state, + }, + } +}) + +vi.mock('../../datasets-detail-store/store', () => ({ + useDatasetsDetailStore: () => ({}), +})) + +vi.mock('../index', () => ({ + useGetToolIcon: () => () => undefined, + useNodesMetaData: () => ({ nodes: [], nodesMap: mockNodesMap }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: vi.fn() }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en', +})) + +// useWorkflowNodes reads from WorkflowContext (real store via renderWorkflowHook) + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +function setupNodesMap() { + mockNodesMap[BlockEnum.Start] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: true, isRequired: false }, + } + mockNodesMap[BlockEnum.Code] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } + mockNodesMap[BlockEnum.LLM] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } + mockNodesMap[BlockEnum.End] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } + mockNodesMap[BlockEnum.Tool] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } +} + +beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + resetFixtureCounters() + Object.keys(mockNodesMap).forEach(k => delete mockNodesMap[k]) + setupNodesMap() +}) + +// --------------------------------------------------------------------------- +// Helper: build a simple connected graph +// --------------------------------------------------------------------------- + +function buildConnectedGraph() { + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) + const endNode = createNode({ id: 'end', data: { type: BlockEnum.End, title: 'End' } }) + const nodes = [startNode, codeNode, endNode] + const edges = [ + createEdge({ source: 'start', target: 'code' }), + createEdge({ source: 'code', target: 'end' }), + ] + return { nodes, edges } +} + +// --------------------------------------------------------------------------- +// useChecklist +// --------------------------------------------------------------------------- + +describe('useChecklist', () => { + it('should return empty list when all nodes are valid and connected', () => { + const { nodes, edges } = buildConnectedGraph() + + const { result } = renderWorkflowHook( + () => useChecklist(nodes, edges), + ) + + expect(result.current).toEqual([]) + }) + + it('should detect disconnected nodes', () => { + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) + const isolatedLlm = createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: 'LLM' } }) + + const edges = [ + createEdge({ source: 'start', target: 'code' }), + ] + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, codeNode, isolatedLlm], edges), + ) + + const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') + expect(warning).toBeDefined() + expect(warning!.unConnected).toBe(true) + }) + + it('should detect validation errors from checkValid', () => { + mockNodesMap[BlockEnum.LLM] = { + checkValid: () => ({ errorMessage: 'Model not configured' }), + metaData: { isStart: false, isRequired: false }, + } + + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const llmNode = createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: 'LLM' } }) + + const edges = [ + createEdge({ source: 'start', target: 'llm' }), + ] + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, llmNode], edges), + ) + + const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') + expect(warning).toBeDefined() + expect(warning!.errorMessage).toBe('Model not configured') + }) + + it('should report missing start node in workflow mode', () => { + const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) + + const { result } = renderWorkflowHook( + () => useChecklist([codeNode], []), + ) + + const startRequired = result.current.find((item: ChecklistItem) => item.id === 'start-node-required') + expect(startRequired).toBeDefined() + expect(startRequired!.canNavigate).toBe(false) + }) + + it('should detect plugin not installed', () => { + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const toolNode = createNode({ + id: 'tool', + data: { + type: BlockEnum.Tool, + title: 'My Tool', + _pluginInstallLocked: true, + }, + }) + + const edges = [ + createEdge({ source: 'start', target: 'tool' }), + ] + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, toolNode], edges), + ) + + const warning = result.current.find((item: ChecklistItem) => item.id === 'tool') + expect(warning).toBeDefined() + expect(warning!.canNavigate).toBe(false) + expect(warning!.disableGoTo).toBe(true) + }) + + it('should report required node types that are missing', () => { + mockNodesMap[BlockEnum.End] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: true }, + } + + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + + const { result } = renderWorkflowHook( + () => useChecklist([startNode], []), + ) + + const requiredItem = result.current.find((item: ChecklistItem) => item.id === `${BlockEnum.End}-need-added`) + expect(requiredItem).toBeDefined() + expect(requiredItem!.canNavigate).toBe(false) + }) + + it('should not flag start nodes as unconnected', () => { + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, codeNode], []), + ) + + const startWarning = result.current.find((item: ChecklistItem) => item.id === 'start') + expect(startWarning).toBeUndefined() + }) + + it('should skip nodes without CUSTOM_NODE type', () => { + const nonCustomNode = createNode({ + id: 'alien', + type: 'not-custom', + data: { type: BlockEnum.Code, title: 'Non-Custom' }, + }) + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, nonCustomNode], []), + ) + + const alienWarning = result.current.find((item: ChecklistItem) => item.id === 'alien') + expect(alienWarning).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// useWorkflowRunValidation +// --------------------------------------------------------------------------- + +describe('useWorkflowRunValidation', () => { + it('should return hasValidationErrors false when there are no warnings', () => { + const { nodes, edges } = buildConnectedGraph() + rfState.edges = edges as unknown as typeof rfState.edges + + const { result } = renderWorkflowHook(() => useWorkflowRunValidation(), { + initialStoreState: { nodes: nodes as Node[] }, + }) + + expect(result.current.hasValidationErrors).toBe(false) + expect(result.current.warningNodes).toEqual([]) + }) + + it('should return validateBeforeRun as a function that returns true when valid', () => { + const { nodes, edges } = buildConnectedGraph() + rfState.edges = edges as unknown as typeof rfState.edges + + const { result } = renderWorkflowHook(() => useWorkflowRunValidation(), { + initialStoreState: { nodes: nodes as Node[] }, + }) + + expect(typeof result.current.validateBeforeRun).toBe('function') + expect(result.current.validateBeforeRun()).toBe(true) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts new file mode 100644 index 0000000000..6d19862efd --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts @@ -0,0 +1,151 @@ +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useEdgesInteractions } from '../use-edges-interactions' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +// useWorkflowHistory uses a debounced save — mock for synchronous assertions +const mockSaveStateToHistory = vi.fn() +vi.mock('../use-workflow-history', () => ({ + useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }), + WorkflowHistoryEvent: { + EdgeDelete: 'EdgeDelete', + EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch', + EdgeSourceHandleChange: 'EdgeSourceHandleChange', + }, +})) + +// use-workflow.ts has heavy transitive imports — mock only useNodesReadOnly +let mockReadOnly = false +vi.mock('../use-workflow', () => ({ + useNodesReadOnly: () => ({ + getNodesReadOnly: () => mockReadOnly, + }), +})) + +vi.mock('../../utils', () => ({ + getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})), +})) + +// useNodesSyncDraft is used REAL — via renderWorkflowHook + hooksStoreProps +function renderEdgesInteractions() { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + return { + ...renderWorkflowHook(() => useEdgesInteractions(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }), + mockDoSync, + } +} + +describe('useEdgesInteractions', () => { + beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + mockReadOnly = false + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: {} }, + { id: 'n2', position: { x: 100, y: 0 }, data: {} }, + ] + rfState.edges = [ + { id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false } }, + { id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false } }, + ] + }) + + it('handleEdgeEnter should set _hovering to true', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeEnter({} as never, rfState.edges[0] as never) + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated.find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(true) + expect(updated.find((e: { id: string }) => e.id === 'e2').data._hovering).toBe(false) + }) + + it('handleEdgeLeave should set _hovering to false', () => { + rfState.edges[0].data._hovering = true + const { result } = renderEdgesInteractions() + result.current.handleEdgeLeave({} as never, rfState.edges[0] as never) + + expect(rfState.setEdges.mock.calls[0][0].find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(false) + }) + + it('handleEdgesChange should update edge.selected for select changes', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgesChange([ + { type: 'select', id: 'e1', selected: true }, + { type: 'select', id: 'e2', selected: false }, + ]) + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(true) + expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false) + }) + + it('handleEdgeDelete should remove selected edge and trigger sync + history', () => { + ;(rfState.edges[0] as Record).selected = true + const { result } = renderEdgesInteractions() + + result.current.handleEdgeDelete() + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated).toHaveLength(1) + expect(updated[0].id).toBe('e2') + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') + }) + + it('handleEdgeDelete should do nothing when no edge is selected', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeDelete() + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + + it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a') + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated).toHaveLength(1) + expect(updated[0].id).toBe('e2') + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch') + }) + + it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', () => { + rfState.edges = [ + { id: 'n1-old-handle-n2-target', source: 'n1', target: 'n2', sourceHandle: 'old-handle', targetHandle: 'target', data: {} } as typeof rfState.edges[0], + ] + + const { result } = renderEdgesInteractions() + result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle') + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated[0].sourceHandle).toBe('new-handle') + expect(updated[0].id).toBe('n1-new-handle-n2-target') + }) + + describe('read-only mode', () => { + beforeEach(() => { + mockReadOnly = true + }) + + it('handleEdgeEnter should do nothing', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeEnter({} as never, rfState.edges[0] as never) + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + + it('handleEdgeDelete should do nothing', () => { + ;(rfState.edges[0] as Record).selected = true + const { result } = renderEdgesInteractions() + result.current.handleEdgeDelete() + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + + it('handleEdgeDeleteByDeleteBranch should do nothing', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a') + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts b/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts new file mode 100644 index 0000000000..d75e39a733 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts @@ -0,0 +1,194 @@ +import type { Node } from '../../types' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import { useHelpline } from '../use-helpline' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +function makeNode(overrides: Record & { id: string }): Node { + return { + position: { x: 0, y: 0 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM, title: '', desc: '' }, + ...overrides, + } as unknown as Node +} + +describe('useHelpline', () => { + beforeEach(() => { + resetReactFlowMockState() + }) + + it('should return empty arrays for nodes in iteration', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', data: { type: BlockEnum.LLM, isInIteration: true } }) + const output = result.current.handleSetHelpline(draggingNode) + + expect(output.showHorizontalHelpLineNodes).toEqual([]) + expect(output.showVerticalHelpLineNodes).toEqual([]) + }) + + it('should return empty arrays for nodes in loop', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', data: { type: BlockEnum.LLM, isInLoop: true } }) + const output = result.current.handleSetHelpline(draggingNode) + + expect(output.showHorizontalHelpLineNodes).toEqual([]) + expect(output.showVerticalHelpLineNodes).toEqual([]) + }) + + it('should detect horizontally aligned nodes (same y ±5px)', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 300, y: 103 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n3', position: { x: 600, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 0, y: 100 } }) + const output = result.current.handleSetHelpline(draggingNode) + + const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id) + expect(horizontalIds).toContain('n2') + expect(horizontalIds).not.toContain('n3') + }) + + it('should detect vertically aligned nodes (same x ±5px)', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 100, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 102, y: 200 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n3', position: { x: 500, y: 400 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 100, y: 0 } }) + const output = result.current.handleSetHelpline(draggingNode) + + const verticalIds = output.showVerticalHelpLineNodes.map((n: { id: string }) => n.id) + expect(verticalIds).toContain('n2') + expect(verticalIds).not.toContain('n3') + }) + + it('should apply entry node offset for Start nodes', () => { + const ENTRY_OFFSET_Y = 21 + + rfState.nodes = [ + { id: 'start', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.Start } }, + { id: 'n2', position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'far', position: { x: 300, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ + id: 'start', + position: { x: 100, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.Start }, + }) + const output = result.current.handleSetHelpline(draggingNode) + + const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id) + expect(horizontalIds).toContain('n2') + expect(horizontalIds).not.toContain('far') + }) + + it('should apply entry node offset for Trigger nodes', () => { + const ENTRY_OFFSET_Y = 21 + + rfState.nodes = [ + { id: 'trigger', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.TriggerWebhook } }, + { id: 'n2', position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ + id: 'trigger', + position: { x: 100, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.TriggerWebhook }, + }) + const output = result.current.handleSetHelpline(draggingNode) + + const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id) + expect(horizontalIds).toContain('n2') + }) + + it('should not detect alignment when positions differ by more than 5px', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 300, y: 106 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n3', position: { x: 106, y: 300 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 100, y: 100 } }) + const output = result.current.handleSetHelpline(draggingNode) + + expect(output.showHorizontalHelpLineNodes).toHaveLength(0) + expect(output.showVerticalHelpLineNodes).toHaveLength(0) + }) + + it('should exclude child nodes in iteration', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'child', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM, isInIteration: true } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 100, y: 100 } }) + const output = result.current.handleSetHelpline(draggingNode) + + const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id) + expect(horizontalIds).not.toContain('child') + }) + + it('should set helpLineHorizontal in store when aligned nodes found', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result, store } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 0, y: 100 } }) + result.current.handleSetHelpline(draggingNode) + + expect(store.getState().helpLineHorizontal).toBeDefined() + }) + + it('should clear helpLineHorizontal when no aligned nodes', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 300, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result, store } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 0, y: 100 } }) + result.current.handleSetHelpline(draggingNode) + + expect(store.getState().helpLineHorizontal).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts b/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts new file mode 100644 index 0000000000..38bfa4839e --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts @@ -0,0 +1,79 @@ +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useDSL } from '../use-DSL' +import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft' +import { useWorkflowRun } from '../use-workflow-run' +import { useWorkflowStartRun } from '../use-workflow-start-run' + +describe('useDSL', () => { + it('should return exportCheck and handleExportDSL from hooksStore', () => { + const mockExportCheck = vi.fn() + const mockHandleExportDSL = vi.fn() + + const { result } = renderWorkflowHook(() => useDSL(), { + hooksStoreProps: { exportCheck: mockExportCheck, handleExportDSL: mockHandleExportDSL }, + }) + + expect(result.current.exportCheck).toBe(mockExportCheck) + expect(result.current.handleExportDSL).toBe(mockHandleExportDSL) + }) +}) + +describe('useWorkflowRun', () => { + it('should return all run-related handlers from hooksStore', () => { + const mocks = { + handleBackupDraft: vi.fn(), + handleLoadBackupDraft: vi.fn(), + handleRestoreFromPublishedWorkflow: vi.fn(), + handleRun: vi.fn(), + handleStopRun: vi.fn(), + } + + const { result } = renderWorkflowHook(() => useWorkflowRun(), { + hooksStoreProps: mocks, + }) + + expect(result.current.handleBackupDraft).toBe(mocks.handleBackupDraft) + expect(result.current.handleLoadBackupDraft).toBe(mocks.handleLoadBackupDraft) + expect(result.current.handleRestoreFromPublishedWorkflow).toBe(mocks.handleRestoreFromPublishedWorkflow) + expect(result.current.handleRun).toBe(mocks.handleRun) + expect(result.current.handleStopRun).toBe(mocks.handleStopRun) + }) +}) + +describe('useWorkflowStartRun', () => { + it('should return all start-run handlers from hooksStore', () => { + const mocks = { + handleStartWorkflowRun: vi.fn(), + handleWorkflowStartRunInWorkflow: vi.fn(), + handleWorkflowStartRunInChatflow: vi.fn(), + handleWorkflowTriggerScheduleRunInWorkflow: vi.fn(), + handleWorkflowTriggerWebhookRunInWorkflow: vi.fn(), + handleWorkflowTriggerPluginRunInWorkflow: vi.fn(), + handleWorkflowRunAllTriggersInWorkflow: vi.fn(), + } + + const { result } = renderWorkflowHook(() => useWorkflowStartRun(), { + hooksStoreProps: mocks, + }) + + expect(result.current.handleStartWorkflowRun).toBe(mocks.handleStartWorkflowRun) + expect(result.current.handleWorkflowStartRunInWorkflow).toBe(mocks.handleWorkflowStartRunInWorkflow) + expect(result.current.handleWorkflowStartRunInChatflow).toBe(mocks.handleWorkflowStartRunInChatflow) + expect(result.current.handleWorkflowTriggerScheduleRunInWorkflow).toBe(mocks.handleWorkflowTriggerScheduleRunInWorkflow) + expect(result.current.handleWorkflowTriggerWebhookRunInWorkflow).toBe(mocks.handleWorkflowTriggerWebhookRunInWorkflow) + expect(result.current.handleWorkflowTriggerPluginRunInWorkflow).toBe(mocks.handleWorkflowTriggerPluginRunInWorkflow) + expect(result.current.handleWorkflowRunAllTriggersInWorkflow).toBe(mocks.handleWorkflowRunAllTriggersInWorkflow) + }) +}) + +describe('useWorkflowRefreshDraft', () => { + it('should return handleRefreshWorkflowDraft from hooksStore', () => { + const mockRefresh = vi.fn() + + const { result } = renderWorkflowHook(() => useWorkflowRefreshDraft(), { + hooksStoreProps: { handleRefreshWorkflowDraft: mockRefresh }, + }) + + expect(result.current.handleRefreshWorkflowDraft).toBe(mockRefresh) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts b/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts new file mode 100644 index 0000000000..7fcb10ff0e --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts @@ -0,0 +1,99 @@ +import type { WorkflowRunningData } from '../../types' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { WorkflowRunningStatus } from '../../types' +import { useNodeDataUpdate } from '../use-node-data-update' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +describe('useNodeDataUpdate', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Node 1', value: 'original' } }, + { id: 'node-2', position: { x: 300, y: 0 }, data: { title: 'Node 2' } }, + ] + }) + + describe('handleNodeDataUpdate', () => { + it('should merge data into the target node and call setNodes', () => { + const { result } = renderWorkflowHook(() => useNodeDataUpdate(), { + hooksStoreProps: {}, + }) + + result.current.handleNodeDataUpdate({ + id: 'node-1', + data: { value: 'updated', extra: true }, + }) + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes.find((n: { id: string }) => n.id === 'node-1').data).toEqual({ + title: 'Node 1', + value: 'updated', + extra: true, + }) + expect(updatedNodes.find((n: { id: string }) => n.id === 'node-2').data).toEqual({ + title: 'Node 2', + }) + }) + }) + + describe('handleNodeDataUpdateWithSyncDraft', () => { + it('should update node data and trigger debounced sync draft', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result, store } = renderWorkflowHook(() => useNodeDataUpdate(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleNodeDataUpdateWithSyncDraft({ + id: 'node-1', + data: { value: 'synced' }, + }) + + expect(rfState.setNodes).toHaveBeenCalledOnce() + + store.getState().flushPendingSync() + expect(mockDoSync).toHaveBeenCalledOnce() + }) + + it('should call doSyncWorkflowDraft directly when sync=true', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + const callback = { onSuccess: vi.fn() } + + const { result } = renderWorkflowHook(() => useNodeDataUpdate(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleNodeDataUpdateWithSyncDraft( + { id: 'node-1', data: { value: 'synced' } }, + { sync: true, notRefreshWhenSyncError: true, callback }, + ) + + expect(mockDoSync).toHaveBeenCalledWith(true, callback) + }) + + it('should do nothing when nodes are read-only', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result } = renderWorkflowHook(() => useNodeDataUpdate(), { + initialStoreState: { + workflowRunningData: { + result: { status: WorkflowRunningStatus.Running }, + } as WorkflowRunningData, + }, + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleNodeDataUpdateWithSyncDraft({ + id: 'node-1', + data: { value: 'should-not-update' }, + }) + + expect(rfState.setNodes).not.toHaveBeenCalled() + expect(mockDoSync).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-sync-draft.spec.ts new file mode 100644 index 0000000000..100692b22a --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -0,0 +1,79 @@ +import type { WorkflowRunningData } from '../../types' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { WorkflowRunningStatus } from '../../types' +import { useNodesSyncDraft } from '../use-nodes-sync-draft' + +describe('useNodesSyncDraft', () => { + it('should return doSyncWorkflowDraft, handleSyncWorkflowDraft, and syncWorkflowDraftWhenPageClose', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + const mockSyncClose = vi.fn() + + const { result } = renderWorkflowHook(() => useNodesSyncDraft(), { + hooksStoreProps: { + doSyncWorkflowDraft: mockDoSync, + syncWorkflowDraftWhenPageClose: mockSyncClose, + }, + }) + + expect(result.current.doSyncWorkflowDraft).toBe(mockDoSync) + expect(result.current.syncWorkflowDraftWhenPageClose).toBe(mockSyncClose) + expect(typeof result.current.handleSyncWorkflowDraft).toBe('function') + }) + + it('should call doSyncWorkflowDraft synchronously when sync=true', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result } = renderWorkflowHook(() => useNodesSyncDraft(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + const callback = { onSuccess: vi.fn() } + result.current.handleSyncWorkflowDraft(true, false, callback) + + expect(mockDoSync).toHaveBeenCalledWith(false, callback) + }) + + it('should use debounced path when sync is falsy, then flush triggers doSync', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result, store } = renderWorkflowHook(() => useNodesSyncDraft(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleSyncWorkflowDraft() + + expect(mockDoSync).not.toHaveBeenCalled() + + store.getState().flushPendingSync() + expect(mockDoSync).toHaveBeenCalledOnce() + }) + + it('should do nothing when nodes are read-only (workflow running)', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result } = renderWorkflowHook(() => useNodesSyncDraft(), { + initialStoreState: { + workflowRunningData: { + result: { status: WorkflowRunningStatus.Running }, + } as WorkflowRunningData, + }, + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleSyncWorkflowDraft(true) + + expect(mockDoSync).not.toHaveBeenCalled() + }) + + it('should pass notRefreshWhenSyncError to doSyncWorkflowDraft', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result } = renderWorkflowHook(() => useNodesSyncDraft(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleSyncWorkflowDraft(true, true) + + expect(mockDoSync).toHaveBeenCalledWith(true, undefined) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts new file mode 100644 index 0000000000..ec689f23f9 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -0,0 +1,78 @@ +import type * as React from 'react' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { usePanelInteractions } from '../use-panel-interactions' + +describe('usePanelInteractions', () => { + let container: HTMLDivElement + + beforeEach(() => { + container = document.createElement('div') + container.id = 'workflow-container' + container.getBoundingClientRect = vi.fn().mockReturnValue({ + x: 100, + y: 50, + width: 800, + height: 600, + top: 50, + right: 900, + bottom: 650, + left: 100, + }) + document.body.appendChild(container) + }) + + afterEach(() => { + container.remove() + }) + + it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => { + const { result, store } = renderWorkflowHook(() => usePanelInteractions()) + const preventDefault = vi.fn() + + result.current.handlePaneContextMenu({ + preventDefault, + clientX: 350, + clientY: 250, + } as unknown as React.MouseEvent) + + expect(preventDefault).toHaveBeenCalled() + expect(store.getState().panelMenu).toEqual({ + top: 200, + left: 250, + }) + }) + + it('handlePaneContextMenu should throw when container does not exist', () => { + container.remove() + + const { result } = renderWorkflowHook(() => usePanelInteractions()) + + expect(() => { + result.current.handlePaneContextMenu({ + preventDefault: vi.fn(), + clientX: 350, + clientY: 250, + } as unknown as React.MouseEvent) + }).toThrow() + }) + + it('handlePaneContextmenuCancel should clear panelMenu', () => { + const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { + initialStoreState: { panelMenu: { top: 10, left: 20 } }, + }) + + result.current.handlePaneContextmenuCancel() + + expect(store.getState().panelMenu).toBeUndefined() + }) + + it('handleNodeContextmenuCancel should clear nodeMenu', () => { + const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { + initialStoreState: { nodeMenu: { top: 10, left: 20, nodeId: 'n1' } }, + }) + + result.current.handleNodeContextmenuCancel() + + expect(store.getState().nodeMenu).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts new file mode 100644 index 0000000000..7e65176e6f --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts @@ -0,0 +1,190 @@ +import type * as React from 'react' +import type { Node, OnSelectionChangeParams } from 'reactflow' +import type { MockEdge, MockNode } from '../../__tests__/reactflow-mock-state' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useSelectionInteractions } from '../use-selection-interactions' + +const rfStoreExtra = vi.hoisted(() => ({ + userSelectionRect: null as { x: number, y: number, width: number, height: number } | null, + userSelectionActive: false, + resetSelectedElements: vi.fn(), + setState: vi.fn(), +})) + +vi.mock('reactflow', async () => { + const mod = await import('../../__tests__/reactflow-mock-state') + const base = mod.createReactFlowModuleMock() + return { + ...base, + useStoreApi: vi.fn(() => ({ + getState: () => ({ + getNodes: () => mod.rfState.nodes, + setNodes: mod.rfState.setNodes, + edges: mod.rfState.edges, + setEdges: mod.rfState.setEdges, + transform: mod.rfState.transform, + userSelectionRect: rfStoreExtra.userSelectionRect, + userSelectionActive: rfStoreExtra.userSelectionActive, + resetSelectedElements: rfStoreExtra.resetSelectedElements, + }), + setState: rfStoreExtra.setState, + subscribe: vi.fn().mockReturnValue(vi.fn()), + })), + } +}) + +describe('useSelectionInteractions', () => { + let container: HTMLDivElement + + beforeEach(() => { + resetReactFlowMockState() + rfStoreExtra.userSelectionRect = null + rfStoreExtra.userSelectionActive = false + rfStoreExtra.resetSelectedElements = vi.fn() + rfStoreExtra.setState.mockReset() + + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _isBundled: true } }, + { id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } }, + { id: 'n3', position: { x: 200, y: 200 }, data: {} }, + ] + rfState.edges = [ + { id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } }, + { id: 'e2', source: 'n2', target: 'n3', data: {} }, + ] + + container = document.createElement('div') + container.id = 'workflow-container' + container.getBoundingClientRect = vi.fn().mockReturnValue({ + x: 100, + y: 50, + width: 800, + height: 600, + top: 50, + right: 900, + bottom: 650, + left: 100, + }) + document.body.appendChild(container) + }) + + afterEach(() => { + container.remove() + }) + + it('handleSelectionStart should clear _isBundled from all nodes and edges', () => { + const { result } = renderWorkflowHook(() => useSelectionInteractions()) + + result.current.handleSelectionStart() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[] + expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true) + + const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[] + expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true) + }) + + it('handleSelectionChange should mark selected nodes as bundled', () => { + rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 } + + const { result } = renderWorkflowHook(() => useSelectionInteractions()) + + result.current.handleSelectionChange({ + nodes: [{ id: 'n1' }, { id: 'n3' }], + edges: [], + } as unknown as OnSelectionChangeParams) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[] + expect(updatedNodes.find(n => n.id === 'n1')!.data._isBundled).toBe(true) + expect(updatedNodes.find(n => n.id === 'n2')!.data._isBundled).toBe(false) + expect(updatedNodes.find(n => n.id === 'n3')!.data._isBundled).toBe(true) + }) + + it('handleSelectionChange should mark selected edges', () => { + rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 } + + const { result } = renderWorkflowHook(() => useSelectionInteractions()) + + result.current.handleSelectionChange({ + nodes: [], + edges: [{ id: 'e1' }], + } as unknown as OnSelectionChangeParams) + + const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[] + expect(updatedEdges.find(e => e.id === 'e1')!.data._isBundled).toBe(true) + expect(updatedEdges.find(e => e.id === 'e2')!.data._isBundled).toBe(false) + }) + + it('handleSelectionDrag should sync node positions', () => { + const { result, store } = renderWorkflowHook(() => useSelectionInteractions()) + + const draggedNodes = [ + { id: 'n1', position: { x: 50, y: 60 }, data: {} }, + ] as unknown as Node[] + + result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes) + + expect(store.getState().nodeAnimation).toBe(false) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[] + expect(updatedNodes.find(n => n.id === 'n1')!.position).toEqual({ x: 50, y: 60 }) + expect(updatedNodes.find(n => n.id === 'n2')!.position).toEqual({ x: 100, y: 100 }) + }) + + it('handleSelectionCancel should clear all selection state', () => { + const { result } = renderWorkflowHook(() => useSelectionInteractions()) + + result.current.handleSelectionCancel() + + expect(rfStoreExtra.setState).toHaveBeenCalledWith({ + userSelectionRect: null, + userSelectionActive: true, + }) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[] + expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true) + + const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[] + expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true) + }) + + it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => { + const { result, store } = renderWorkflowHook(() => useSelectionInteractions()) + + const wrongTarget = document.createElement('div') + wrongTarget.classList.add('some-other-class') + result.current.handleSelectionContextMenu({ + target: wrongTarget, + preventDefault: vi.fn(), + clientX: 300, + clientY: 200, + } as unknown as React.MouseEvent) + + expect(store.getState().selectionMenu).toBeUndefined() + + const correctTarget = document.createElement('div') + correctTarget.classList.add('react-flow__nodesselection-rect') + result.current.handleSelectionContextMenu({ + target: correctTarget, + preventDefault: vi.fn(), + clientX: 300, + clientY: 200, + } as unknown as React.MouseEvent) + + expect(store.getState().selectionMenu).toEqual({ + top: 150, + left: 200, + }) + }) + + it('handleSelectionContextmenuCancel should clear selectionMenu', () => { + const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), { + initialStoreState: { selectionMenu: { top: 50, left: 60 } }, + }) + + result.current.handleSelectionContextmenuCancel() + + expect(store.getState().selectionMenu).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts b/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts new file mode 100644 index 0000000000..bdb2554cd8 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts @@ -0,0 +1,94 @@ +import { act, renderHook } from '@testing-library/react' +import { useSerialAsyncCallback } from '../use-serial-async-callback' + +describe('useSerialAsyncCallback', () => { + it('should execute a synchronous function and return its result', async () => { + const fn = vi.fn((..._args: number[]) => 42) + const { result } = renderHook(() => useSerialAsyncCallback(fn)) + + const value = await act(() => result.current(1, 2)) + + expect(value).toBe(42) + expect(fn).toHaveBeenCalledWith(1, 2) + }) + + it('should execute an async function and return its result', async () => { + const fn = vi.fn(async (x: number) => x * 2) + const { result } = renderHook(() => useSerialAsyncCallback(fn)) + + const value = await act(() => result.current(5)) + + expect(value).toBe(10) + }) + + it('should serialize concurrent calls sequentially', async () => { + const order: number[] = [] + const fn = vi.fn(async (id: number, delay: number) => { + await new Promise(resolve => setTimeout(resolve, delay)) + order.push(id) + return id + }) + + const { result } = renderHook(() => useSerialAsyncCallback(fn)) + + let r1: number | undefined + let r2: number | undefined + let r3: number | undefined + + await act(async () => { + const p1 = result.current(1, 30) + const p2 = result.current(2, 10) + const p3 = result.current(3, 5) + r1 = await p1 + r2 = await p2 + r3 = await p3 + }) + + expect(order).toEqual([1, 2, 3]) + expect(r1).toBe(1) + expect(r2).toBe(2) + expect(r3).toBe(3) + }) + + it('should skip execution when shouldSkip returns true', async () => { + const fn = vi.fn(async () => 'executed') + const shouldSkip = vi.fn(() => true) + const { result } = renderHook(() => useSerialAsyncCallback(fn, shouldSkip)) + + const value = await act(() => result.current()) + + expect(value).toBeUndefined() + expect(fn).not.toHaveBeenCalled() + }) + + it('should execute when shouldSkip returns false', async () => { + const fn = vi.fn(async () => 'executed') + const shouldSkip = vi.fn(() => false) + const { result } = renderHook(() => useSerialAsyncCallback(fn, shouldSkip)) + + const value = await act(() => result.current()) + + expect(value).toBe('executed') + expect(fn).toHaveBeenCalledOnce() + }) + + it('should continue queuing after a previous call rejects', async () => { + let callCount = 0 + const fn = vi.fn(async () => { + callCount++ + if (callCount === 1) + throw new Error('fail') + return 'ok' + }) + + const { result } = renderHook(() => useSerialAsyncCallback(fn)) + + await act(async () => { + await result.current().catch(() => {}) + const value = await result.current() + expect(value).toBe('ok') + }) + + expect(fn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts new file mode 100644 index 0000000000..4ce79d5bf2 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts @@ -0,0 +1,171 @@ +import { CollectionType } from '@/app/components/tools/types' +import { resetReactFlowMockState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import { useGetToolIcon, useToolIcon } from '../use-tool-icon' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +vi.mock('@/service/use-tools', async () => + (await import('../../__tests__/service-mock-factory')).createToolServiceMock({ + buildInTools: [{ id: 'builtin-1', name: 'builtin', icon: '/builtin.svg', icon_dark: '/builtin-dark.svg', plugin_id: 'p1' }], + customTools: [{ id: 'custom-1', name: 'custom', icon: '/custom.svg', plugin_id: 'p2' }], + })) + +vi.mock('@/service/use-triggers', async () => + (await import('../../__tests__/service-mock-factory')).createTriggerServiceMock({ + triggerPlugins: [{ id: 'trigger-1', icon: '/trigger.svg', icon_dark: '/trigger-dark.svg' }], + })) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/utils', () => ({ + canFindTool: (id: string, target: string) => id === target, +})) + +const baseNodeData = { title: '', desc: '' } + +describe('useToolIcon', () => { + beforeEach(() => { + resetReactFlowMockState() + }) + + it('should return empty string when no data', () => { + const { result } = renderWorkflowHook(() => useToolIcon(undefined)) + expect(result.current).toBe('') + }) + + it('should find icon for TriggerPlugin node', () => { + const data = { + ...baseNodeData, + type: BlockEnum.TriggerPlugin, + plugin_id: 'trigger-1', + provider_id: 'trigger-1', + provider_name: 'trigger-1', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('/trigger.svg') + }) + + it('should find icon for Tool node (builtIn)', () => { + const data = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.builtIn, + provider_id: 'builtin-1', + plugin_id: 'p1', + provider_name: 'builtin', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('/builtin.svg') + }) + + it('should find icon for Tool node (custom)', () => { + const data = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.custom, + provider_id: 'custom-1', + plugin_id: 'p2', + provider_name: 'custom', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('/custom.svg') + }) + + it('should fallback to provider_icon when no collection match', () => { + const data = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.builtIn, + provider_id: 'unknown-provider', + plugin_id: 'unknown-plugin', + provider_name: 'unknown', + provider_icon: '/fallback.svg', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('/fallback.svg') + }) + + it('should return empty string for unmatched DataSource node', () => { + const data = { + ...baseNodeData, + type: BlockEnum.DataSource, + plugin_id: 'unknown-ds', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('') + }) + + it('should return empty string for unrecognized node type', () => { + const data = { + ...baseNodeData, + type: BlockEnum.LLM, + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('') + }) +}) + +describe('useGetToolIcon', () => { + beforeEach(() => { + resetReactFlowMockState() + }) + + it('should return a function', () => { + const { result } = renderWorkflowHook(() => useGetToolIcon()) + expect(typeof result.current).toBe('function') + }) + + it('should find icon for TriggerPlugin node via returned function', () => { + const { result } = renderWorkflowHook(() => useGetToolIcon()) + + const data = { + ...baseNodeData, + type: BlockEnum.TriggerPlugin, + plugin_id: 'trigger-1', + provider_id: 'trigger-1', + provider_name: 'trigger-1', + } + + const icon = result.current(data) + expect(icon).toBe('/trigger.svg') + }) + + it('should find icon for Tool node via returned function', () => { + const { result } = renderWorkflowHook(() => useGetToolIcon()) + + const data = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.builtIn, + provider_id: 'builtin-1', + plugin_id: 'p1', + provider_name: 'builtin', + } + + const icon = result.current(data) + expect(icon).toBe('/builtin.svg') + }) + + it('should return undefined for unmatched node type', () => { + const { result } = renderWorkflowHook(() => useGetToolIcon()) + + const data = { + ...baseNodeData, + type: BlockEnum.LLM, + } + + const icon = result.current(data) + expect(icon).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts new file mode 100644 index 0000000000..9544c401cf --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts @@ -0,0 +1,130 @@ +import { renderHook } from '@testing-library/react' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { NodeRunningStatus } from '../../types' +import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync' +import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +describe('useEdgesInteractionsWithoutSync', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.edges = [ + { id: 'e1', source: 'a', target: 'b', data: { _sourceRunningStatus: 'running', _targetRunningStatus: 'running', _waitingRun: true } }, + { id: 'e2', source: 'b', target: 'c', data: { _sourceRunningStatus: 'succeeded', _targetRunningStatus: undefined, _waitingRun: false } }, + ] + }) + + it('should clear running status and waitingRun on all edges', () => { + const { result } = renderHook(() => useEdgesInteractionsWithoutSync()) + + result.current.handleEdgeCancelRunningStatus() + + expect(rfState.setEdges).toHaveBeenCalledOnce() + const updated = rfState.setEdges.mock.calls[0][0] + for (const edge of updated) { + expect(edge.data._sourceRunningStatus).toBeUndefined() + expect(edge.data._targetRunningStatus).toBeUndefined() + expect(edge.data._waitingRun).toBe(false) + } + }) + + it('should not mutate original edges', () => { + const originalData = { ...rfState.edges[0].data } + const { result } = renderHook(() => useEdgesInteractionsWithoutSync()) + + result.current.handleEdgeCancelRunningStatus() + + expect(rfState.edges[0].data._sourceRunningStatus).toBe(originalData._sourceRunningStatus) + }) +}) + +describe('useNodesInteractionsWithoutSync', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }, + { id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }, + { id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }, + ] + }) + + describe('handleNodeCancelRunningStatus', () => { + it('should clear _runningStatus and _waitingRun on all nodes', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleNodeCancelRunningStatus() + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updated = rfState.setNodes.mock.calls[0][0] + for (const node of updated) { + expect(node.data._runningStatus).toBeUndefined() + expect(node.data._waitingRun).toBe(false) + } + }) + }) + + describe('handleCancelAllNodeSuccessStatus', () => { + it('should clear _runningStatus only for Succeeded nodes', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelAllNodeSuccessStatus() + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updated = rfState.setNodes.mock.calls[0][0] + const n1 = updated.find((n: { id: string }) => n.id === 'n1') + const n2 = updated.find((n: { id: string }) => n.id === 'n2') + const n3 = updated.find((n: { id: string }) => n.id === 'n3') + + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n2.data._runningStatus).toBeUndefined() + expect(n3.data._runningStatus).toBe(NodeRunningStatus.Failed) + }) + + it('should not modify _waitingRun', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelAllNodeSuccessStatus() + + const updated = rfState.setNodes.mock.calls[0][0] + expect(updated.find((n: { id: string }) => n.id === 'n1').data._waitingRun).toBe(true) + expect(updated.find((n: { id: string }) => n.id === 'n3').data._waitingRun).toBe(true) + }) + }) + + describe('handleCancelNodeSuccessStatus', () => { + it('should clear _runningStatus and _waitingRun for the specified Succeeded node', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelNodeSuccessStatus('n2') + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updated = rfState.setNodes.mock.calls[0][0] + const n2 = updated.find((n: { id: string }) => n.id === 'n2') + expect(n2.data._runningStatus).toBeUndefined() + expect(n2.data._waitingRun).toBe(false) + }) + + it('should not modify nodes that are not Succeeded', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelNodeSuccessStatus('n1') + + const updated = rfState.setNodes.mock.calls[0][0] + const n1 = updated.find((n: { id: string }) => n.id === 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n1.data._waitingRun).toBe(true) + }) + + it('should not modify other nodes', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelNodeSuccessStatus('n2') + + const updated = rfState.setNodes.mock.calls[0][0] + const n1 = updated.find((n: { id: string }) => n.id === 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-mode.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-mode.spec.ts new file mode 100644 index 0000000000..856ada37ed --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-mode.spec.ts @@ -0,0 +1,47 @@ +import type { HistoryWorkflowData } from '../../types' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useWorkflowMode } from '../use-workflow-mode' + +describe('useWorkflowMode', () => { + it('should return normal mode when no history data and not restoring', () => { + const { result } = renderWorkflowHook(() => useWorkflowMode()) + + expect(result.current.normal).toBe(true) + expect(result.current.restoring).toBe(false) + expect(result.current.viewHistory).toBe(false) + }) + + it('should return restoring mode when isRestoring is true', () => { + const { result } = renderWorkflowHook(() => useWorkflowMode(), { + initialStoreState: { isRestoring: true }, + }) + + expect(result.current.normal).toBe(false) + expect(result.current.restoring).toBe(true) + expect(result.current.viewHistory).toBe(false) + }) + + it('should return viewHistory mode when historyWorkflowData exists', () => { + const { result } = renderWorkflowHook(() => useWorkflowMode(), { + initialStoreState: { + historyWorkflowData: { id: 'v1', status: 'succeeded' } as HistoryWorkflowData, + }, + }) + + expect(result.current.normal).toBe(false) + expect(result.current.restoring).toBe(false) + expect(result.current.viewHistory).toBe(true) + }) + + it('should prioritize restoring over viewHistory when both are set', () => { + const { result } = renderWorkflowHook(() => useWorkflowMode(), { + initialStoreState: { + isRestoring: true, + historyWorkflowData: { id: 'v1', status: 'succeeded' } as HistoryWorkflowData, + }, + }) + + expect(result.current.restoring).toBe(true) + expect(result.current.normal).toBe(false) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts new file mode 100644 index 0000000000..2085e5ab47 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts @@ -0,0 +1,242 @@ +import type { + AgentLogResponse, + HumanInputFormFilledResponse, + HumanInputFormTimeoutResponse, + TextChunkResponse, + TextReplaceResponse, + WorkflowFinishedResponse, +} from '@/types/workflow' +import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { WorkflowRunningStatus } from '../../types' +import { useWorkflowAgentLog } from '../use-workflow-run-event/use-workflow-agent-log' +import { useWorkflowFailed } from '../use-workflow-run-event/use-workflow-failed' +import { useWorkflowFinished } from '../use-workflow-run-event/use-workflow-finished' +import { useWorkflowNodeHumanInputFormFilled } from '../use-workflow-run-event/use-workflow-node-human-input-form-filled' +import { useWorkflowNodeHumanInputFormTimeout } from '../use-workflow-run-event/use-workflow-node-human-input-form-timeout' +import { useWorkflowPaused } from '../use-workflow-run-event/use-workflow-paused' +import { useWorkflowTextChunk } from '../use-workflow-run-event/use-workflow-text-chunk' +import { useWorkflowTextReplace } from '../use-workflow-run-event/use-workflow-text-replace' + +vi.mock('@/app/components/base/file-uploader/utils', () => ({ + getFilesInLogs: vi.fn(() => []), +})) + +describe('useWorkflowFailed', () => { + it('should set status to Failed', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowFailed(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowFailed() + + expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Failed) + }) +}) + +describe('useWorkflowPaused', () => { + it('should set status to Paused', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowPaused(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowPaused() + + expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Paused) + }) +}) + +describe('useWorkflowTextChunk', () => { + it('should append text and activate result tab', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), { + initialStoreState: { + workflowRunningData: baseRunningData({ resultText: 'Hello' }), + }, + }) + + result.current.handleWorkflowTextChunk({ data: { text: ' World' } } as TextChunkResponse) + + const state = store.getState().workflowRunningData! + expect(state.resultText).toBe('Hello World') + expect(state.resultTabActive).toBe(true) + }) +}) + +describe('useWorkflowTextReplace', () => { + it('should replace resultText', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowTextReplace(), { + initialStoreState: { + workflowRunningData: baseRunningData({ resultText: 'old text' }), + }, + }) + + result.current.handleWorkflowTextReplace({ data: { text: 'new text' } } as TextReplaceResponse) + + expect(store.getState().workflowRunningData!.resultText).toBe('new text') + }) +}) + +describe('useWorkflowFinished', () => { + it('should merge data into result and activate result tab for single string output', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowFinished({ + data: { status: 'succeeded', outputs: { answer: 'hello' } }, + } as WorkflowFinishedResponse) + + const state = store.getState().workflowRunningData! + expect(state.result.status).toBe('succeeded') + expect(state.resultTabActive).toBe(true) + expect(state.resultText).toBe('hello') + }) + + it('should not activate result tab for multi-key outputs', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowFinished({ + data: { status: 'succeeded', outputs: { a: 'hello', b: 'world' } }, + } as WorkflowFinishedResponse) + + expect(store.getState().workflowRunningData!.resultTabActive).toBeFalsy() + }) +}) + +describe('useWorkflowAgentLog', () => { + it('should create agent_log array when execution_metadata has no agent_log', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n1', execution_metadata: {} }], + }), + }, + }) + + result.current.handleWorkflowAgentLog({ + data: { node_id: 'n1', message_id: 'm1' }, + } as AgentLogResponse) + + const trace = store.getState().workflowRunningData!.tracing![0] + expect(trace.execution_metadata!.agent_log).toHaveLength(1) + expect(trace.execution_metadata!.agent_log![0].message_id).toBe('m1') + }) + + it('should append to existing agent_log', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ + node_id: 'n1', + execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] }, + }], + }), + }, + }) + + result.current.handleWorkflowAgentLog({ + data: { node_id: 'n1', message_id: 'm2' }, + } as AgentLogResponse) + + expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2) + }) + + it('should update existing log entry by message_id', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ + node_id: 'n1', + execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] }, + }], + }), + }, + }) + + result.current.handleWorkflowAgentLog({ + data: { node_id: 'n1', message_id: 'm1', text: 'new' }, + } as unknown as AgentLogResponse) + + const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log! + expect(log).toHaveLength(1) + expect((log[0] as unknown as { text: string }).text).toBe('new') + }) + + it('should create execution_metadata when it does not exist', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n1' }], + }), + }, + }) + + result.current.handleWorkflowAgentLog({ + data: { node_id: 'n1', message_id: 'm1' }, + } as AgentLogResponse) + + expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1) + }) +}) + +describe('useWorkflowNodeHumanInputFormFilled', () => { + it('should remove form from humanInputFormDataList and add to humanInputFilledFormDataList', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputFormFilled({ + data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' }, + } as HumanInputFormFilledResponse) + + const state = store.getState().workflowRunningData! + expect(state.humanInputFormDataList).toHaveLength(0) + expect(state.humanInputFilledFormDataList).toHaveLength(1) + expect(state.humanInputFilledFormDataList![0].node_id).toBe('n1') + }) + + it('should create humanInputFilledFormDataList when it does not exist', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputFormFilled({ + data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' }, + } as HumanInputFormFilledResponse) + + expect(store.getState().workflowRunningData!.humanInputFilledFormDataList).toBeDefined() + }) +}) + +describe('useWorkflowNodeHumanInputFormTimeout', () => { + it('should set expiration_time on the matching form', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormTimeout(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '', expiration_time: 0 }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputFormTimeout({ + data: { node_id: 'n1', node_title: 'Node 1', expiration_time: 1000 }, + } as HumanInputFormTimeoutResponse) + + expect(store.getState().workflowRunningData!.humanInputFormDataList![0].expiration_time).toBe(1000) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts new file mode 100644 index 0000000000..e40efd3819 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts @@ -0,0 +1,269 @@ +import type { WorkflowRunningData } from '../../types' +import type { + IterationFinishedResponse, + IterationNextResponse, + LoopFinishedResponse, + LoopNextResponse, + NodeFinishedResponse, + WorkflowStartedResponse, +} from '@/types/workflow' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { DEFAULT_ITER_TIMES } from '../../constants' +import { NodeRunningStatus, WorkflowRunningStatus } from '../../types' +import { useWorkflowNodeFinished } from '../use-workflow-run-event/use-workflow-node-finished' +import { useWorkflowNodeIterationFinished } from '../use-workflow-run-event/use-workflow-node-iteration-finished' +import { useWorkflowNodeIterationNext } from '../use-workflow-run-event/use-workflow-node-iteration-next' +import { useWorkflowNodeLoopFinished } from '../use-workflow-run-event/use-workflow-node-loop-finished' +import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-node-loop-next' +import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry' +import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +describe('useWorkflowStarted', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _waitingRun: false } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should initialize workflow running data and reset nodes/edges', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowStarted({ + task_id: 'task-2', + data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 }, + } as WorkflowStartedResponse) + + const state = store.getState().workflowRunningData! + expect(state.task_id).toBe('task-2') + expect(state.result.status).toBe(WorkflowRunningStatus.Running) + expect(state.resultText).toBe('') + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._waitingRun).toBe(true) + + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) + + it('should resume from Paused without resetting nodes/edges', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'], + }), + }, + }) + + result.current.handleWorkflowStarted({ + task_id: 'task-2', + data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 }, + } as WorkflowStartedResponse) + + expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running) + expect(rfState.setNodes).not.toHaveBeenCalled() + expect(rfState.setEdges).not.toHaveBeenCalled() + }) +}) + +describe('useWorkflowNodeFinished', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should update tracing and node running status', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeFinished(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }], + }), + }, + }) + + result.current.handleWorkflowNodeFinished({ + data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, + } as NodeFinishedResponse) + + const trace = store.getState().workflowRunningData!.tracing![0] + expect(trace.status).toBe(NodeRunningStatus.Succeeded) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded) + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) + + it('should set _runningBranchId for IfElse node', () => { + const { result } = renderWorkflowHook(() => useWorkflowNodeFinished(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }], + }), + }, + }) + + result.current.handleWorkflowNodeFinished({ + data: { + id: 'trace-1', + node_id: 'n1', + node_type: 'if-else', + status: NodeRunningStatus.Succeeded, + outputs: { selected_case_id: 'branch-a' }, + }, + } as unknown as NodeFinishedResponse) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._runningBranchId).toBe('branch-a') + }) +}) + +describe('useWorkflowNodeRetry', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: {} }, + ] + }) + + it('should push retry data to tracing and update _retryIndex', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeRetry(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeRetry({ + data: { node_id: 'n1', retry_index: 2 }, + } as NodeFinishedResponse) + + expect(store.getState().workflowRunningData!.tracing).toHaveLength(1) + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._retryIndex).toBe(2) + }) +}) + +describe('useWorkflowNodeIterationNext', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: {} }, + ] + }) + + it('should set _iterationIndex and increment iterTimes', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationNext(), { + initialStoreState: { + workflowRunningData: baseRunningData(), + iterTimes: 3, + }, + }) + + result.current.handleWorkflowNodeIterationNext({ + data: { node_id: 'n1' }, + } as IterationNextResponse) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._iterationIndex).toBe(3) + expect(store.getState().iterTimes).toBe(4) + }) +}) + +describe('useWorkflowNodeIterationFinished', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should update tracing, reset iterTimes, update node status and edges', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationFinished(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }], + }), + iterTimes: 10, + }, + }) + + result.current.handleWorkflowNodeIterationFinished({ + data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, + } as IterationFinishedResponse) + + expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded) + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) +}) + +describe('useWorkflowNodeLoopNext', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: {} }, + { id: 'n2', position: { x: 300, y: 0 }, parentId: 'n1', data: { _waitingRun: false } }, + ] + }) + + it('should set _loopIndex and reset child nodes to waiting', () => { + const { result } = renderWorkflowHook(() => useWorkflowNodeLoopNext(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeLoopNext({ + data: { node_id: 'n1', index: 5 }, + } as LoopNextResponse) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._loopIndex).toBe(5) + expect(updatedNodes[1].data._waitingRun).toBe(true) + expect(updatedNodes[1].data._runningStatus).toBe(NodeRunningStatus.Waiting) + }) +}) + +describe('useWorkflowNodeLoopFinished', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should update tracing, node status and edges', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopFinished(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }], + }), + }, + }) + + result.current.handleWorkflowNodeLoopFinished({ + data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, + } as LoopFinishedResponse) + + const trace = store.getState().workflowRunningData!.tracing![0] + expect(trace.status).toBe(NodeRunningStatus.Succeeded) + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts new file mode 100644 index 0000000000..51d1ba5b74 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts @@ -0,0 +1,244 @@ +import type { + HumanInputRequiredResponse, + IterationStartedResponse, + LoopStartedResponse, + NodeStartedResponse, +} from '@/types/workflow' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { DEFAULT_ITER_TIMES } from '../../constants' +import { NodeRunningStatus } from '../../types' +import { useWorkflowNodeHumanInputRequired } from '../use-workflow-run-event/use-workflow-node-human-input-required' +import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-workflow-node-iteration-started' +import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started' +import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +function findNodeById(nodes: Array<{ id: string, data: Record }>, id: string) { + return nodes.find(n => n.id === id)! +} + +const containerParams = { clientWidth: 1200, clientHeight: 800 } + +describe('useWorkflowNodeStarted', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } }, + { id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } }, + { id: 'n2', position: { x: 400, y: 50 }, width: 200, height: 80, parentId: 'n1', data: { _waitingRun: true } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should push to tracing, set node running, and adjust viewport for root node', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeStarted( + { data: { node_id: 'n1' } } as NodeStartedResponse, + containerParams, + ) + + const tracing = store.getState().workflowRunningData!.tracing! + expect(tracing).toHaveLength(1) + expect(tracing[0].status).toBe(NodeRunningStatus.Running) + + expect(rfState.setViewport).toHaveBeenCalledOnce() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + const n1 = findNodeById(updatedNodes, 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n1.data._waitingRun).toBe(false) + + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) + + it('should not adjust viewport for child node (has parentId)', () => { + const { result } = renderWorkflowHook(() => useWorkflowNodeStarted(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeStarted( + { data: { node_id: 'n2' } } as NodeStartedResponse, + containerParams, + ) + + expect(rfState.setViewport).not.toHaveBeenCalled() + }) + + it('should update existing tracing entry if node_id exists at non-zero index', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [ + { node_id: 'n0', status: NodeRunningStatus.Succeeded }, + { node_id: 'n1', status: NodeRunningStatus.Succeeded }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeStarted( + { data: { node_id: 'n1' } } as NodeStartedResponse, + containerParams, + ) + + const tracing = store.getState().workflowRunningData!.tracing! + expect(tracing).toHaveLength(2) + expect(tracing[1].status).toBe(NodeRunningStatus.Running) + }) +}) + +describe('useWorkflowNodeIterationStarted', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } }, + { id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationStarted(), { + initialStoreState: { + workflowRunningData: baseRunningData(), + iterTimes: 99, + }, + }) + + result.current.handleWorkflowNodeIterationStarted( + { data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse, + containerParams, + ) + + const tracing = store.getState().workflowRunningData!.tracing! + expect(tracing[0].status).toBe(NodeRunningStatus.Running) + + expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES) + expect(rfState.setViewport).toHaveBeenCalledOnce() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + const n1 = findNodeById(updatedNodes, 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n1.data._iterationLength).toBe(10) + expect(n1.data._waitingRun).toBe(false) + + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) +}) + +describe('useWorkflowNodeLoopStarted', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } }, + { id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should push to tracing, set viewport, and update node with _loopLength', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopStarted(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeLoopStarted( + { data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse, + containerParams, + ) + + expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running) + expect(rfState.setViewport).toHaveBeenCalledOnce() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + const n1 = findNodeById(updatedNodes, 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n1.data._loopLength).toBe(5) + expect(n1.data._waitingRun).toBe(false) + + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) +}) + +describe('useWorkflowNodeHumanInputRequired', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + { id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + ] + }) + + it('should create humanInputFormDataList and set tracing/node to Paused', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputRequired({ + data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' }, + } as HumanInputRequiredResponse) + + const state = store.getState().workflowRunningData! + expect(state.humanInputFormDataList).toHaveLength(1) + expect(state.humanInputFormDataList![0].form_id).toBe('f1') + expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(findNodeById(updatedNodes, 'n1').data._runningStatus).toBe(NodeRunningStatus.Paused) + }) + + it('should update existing form entry for same node_id', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }], + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'old', node_title: 'Node 1', form_content: 'old' }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputRequired({ + data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' }, + } as HumanInputRequiredResponse) + + const formList = store.getState().workflowRunningData!.humanInputFormDataList! + expect(formList).toHaveLength(1) + expect(formList[0].form_id).toBe('new') + }) + + it('should append new form entry for different node_id', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }], + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputRequired({ + data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' }, + } as HumanInputRequiredResponse) + + expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts new file mode 100644 index 0000000000..685df81864 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts @@ -0,0 +1,148 @@ +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useWorkflowVariables, useWorkflowVariableType } from '../use-workflow-variables' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +vi.mock('@/service/use-tools', async () => + (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) + +const { mockToNodeAvailableVars, mockGetVarType } = vi.hoisted(() => ({ + mockToNodeAvailableVars: vi.fn((_args: Record) => [] as unknown[]), + mockGetVarType: vi.fn((_args: Record) => 'string' as string), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({ + toNodeAvailableVars: mockToNodeAvailableVars, + getVarType: mockGetVarType, +})) + +vi.mock('../../nodes/_base/components/variable/use-match-schema-type', () => ({ + default: () => ({ schemaTypeDefinitions: [] }), +})) + +let mockIsChatMode = false +vi.mock('../use-workflow', () => ({ + useIsChatMode: () => mockIsChatMode, +})) + +describe('useWorkflowVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getNodeAvailableVars', () => { + it('should call toNodeAvailableVars with store data', () => { + const { result } = renderWorkflowHook(() => useWorkflowVariables(), { + initialStoreState: { + conversationVariables: [{ id: 'cv1' }] as never[], + environmentVariables: [{ id: 'ev1' }] as never[], + }, + }) + + result.current.getNodeAvailableVars({ + beforeNodes: [], + isChatMode: true, + filterVar: () => true, + }) + + expect(mockToNodeAvailableVars).toHaveBeenCalledOnce() + const args = mockToNodeAvailableVars.mock.calls[0][0] + expect(args.isChatMode).toBe(true) + expect(args.conversationVariables).toHaveLength(1) + expect(args.environmentVariables).toHaveLength(1) + }) + + it('should hide env variables when hideEnv is true', () => { + const { result } = renderWorkflowHook(() => useWorkflowVariables(), { + initialStoreState: { + environmentVariables: [{ id: 'ev1' }] as never[], + }, + }) + + result.current.getNodeAvailableVars({ + beforeNodes: [], + isChatMode: false, + filterVar: () => true, + hideEnv: true, + }) + + const args = mockToNodeAvailableVars.mock.calls[0][0] + expect(args.environmentVariables).toEqual([]) + }) + + it('should hide chat variables when not in chat mode', () => { + const { result } = renderWorkflowHook(() => useWorkflowVariables(), { + initialStoreState: { + conversationVariables: [{ id: 'cv1' }] as never[], + }, + }) + + result.current.getNodeAvailableVars({ + beforeNodes: [], + isChatMode: false, + filterVar: () => true, + }) + + const args = mockToNodeAvailableVars.mock.calls[0][0] + expect(args.conversationVariables).toEqual([]) + }) + }) + + describe('getCurrentVariableType', () => { + it('should call getVarType with store data and return the result', () => { + mockGetVarType.mockReturnValue('number') + + const { result } = renderWorkflowHook(() => useWorkflowVariables()) + + const type = result.current.getCurrentVariableType({ + valueSelector: ['node-1', 'output'], + availableNodes: [], + isChatMode: false, + }) + + expect(mockGetVarType).toHaveBeenCalledOnce() + expect(type).toBe('number') + }) + }) +}) + +describe('useWorkflowVariableType', () => { + beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + mockIsChatMode = false + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { isInIteration: false } }, + { id: 'n2', position: { x: 300, y: 0 }, data: { isInIteration: true }, parentId: 'iter-1' }, + { id: 'iter-1', position: { x: 0, y: 200 }, data: {} }, + ] + }) + + it('should return a function', () => { + const { result } = renderWorkflowHook(() => useWorkflowVariableType()) + expect(typeof result.current).toBe('function') + }) + + it('should call getCurrentVariableType with the correct node', () => { + mockGetVarType.mockReturnValue('string') + + const { result } = renderWorkflowHook(() => useWorkflowVariableType()) + const type = result.current({ nodeId: 'n1', valueSelector: ['n1', 'output'] }) + + expect(mockGetVarType).toHaveBeenCalledOnce() + expect(type).toBe('string') + }) + + it('should pass iterationNode as parentNode when node is in iteration', () => { + mockGetVarType.mockReturnValue('array') + + const { result } = renderWorkflowHook(() => useWorkflowVariableType()) + result.current({ nodeId: 'n2', valueSelector: ['n2', 'item'] }) + + const args = mockGetVarType.mock.calls[0][0] + expect(args.parentNode).toBeDefined() + expect((args.parentNode as { id: string }).id).toBe('iter-1') + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts new file mode 100644 index 0000000000..24cc9455cb --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts @@ -0,0 +1,234 @@ +import { act, renderHook } from '@testing-library/react' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { WorkflowRunningStatus } from '../../types' +import { + useIsChatMode, + useIsNodeInIteration, + useIsNodeInLoop, + useNodesReadOnly, + useWorkflowReadOnly, +} from '../use-workflow' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +let mockAppMode = 'workflow' +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { mode: string } }) => unknown) => selector({ appDetail: { mode: mockAppMode } }), +})) + +beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + mockAppMode = 'workflow' +}) + +// --------------------------------------------------------------------------- +// useIsChatMode +// --------------------------------------------------------------------------- + +describe('useIsChatMode', () => { + it('should return true when app mode is advanced-chat', () => { + mockAppMode = 'advanced-chat' + const { result } = renderHook(() => useIsChatMode()) + expect(result.current).toBe(true) + }) + + it('should return false when app mode is workflow', () => { + mockAppMode = 'workflow' + const { result } = renderHook(() => useIsChatMode()) + expect(result.current).toBe(false) + }) + + it('should return false when app mode is chat', () => { + mockAppMode = 'chat' + const { result } = renderHook(() => useIsChatMode()) + expect(result.current).toBe(false) + }) + + it('should return false when app mode is completion', () => { + mockAppMode = 'completion' + const { result } = renderHook(() => useIsChatMode()) + expect(result.current).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// useWorkflowReadOnly +// --------------------------------------------------------------------------- + +describe('useWorkflowReadOnly', () => { + it('should return workflowReadOnly true when status is Running', () => { + const { result } = renderWorkflowHook(() => useWorkflowReadOnly(), { + initialStoreState: { + workflowRunningData: baseRunningData(), + }, + }) + expect(result.current.workflowReadOnly).toBe(true) + }) + + it('should return workflowReadOnly false when status is Succeeded', () => { + const { result } = renderWorkflowHook(() => useWorkflowReadOnly(), { + initialStoreState: { + workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Succeeded } }), + }, + }) + expect(result.current.workflowReadOnly).toBe(false) + }) + + it('should return workflowReadOnly false when no running data', () => { + const { result } = renderWorkflowHook(() => useWorkflowReadOnly()) + expect(result.current.workflowReadOnly).toBe(false) + }) + + it('should expose getWorkflowReadOnly that reads from store state', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowReadOnly()) + + expect(result.current.getWorkflowReadOnly()).toBe(false) + + act(() => { + store.setState({ + workflowRunningData: baseRunningData({ task_id: 'task-2' }), + }) + }) + + expect(result.current.getWorkflowReadOnly()).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// useNodesReadOnly +// --------------------------------------------------------------------------- + +describe('useNodesReadOnly', () => { + it('should return true when status is Running', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly(), { + initialStoreState: { + workflowRunningData: baseRunningData(), + }, + }) + expect(result.current.nodesReadOnly).toBe(true) + }) + + it('should return true when status is Paused', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly(), { + initialStoreState: { + workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Paused } }), + }, + }) + expect(result.current.nodesReadOnly).toBe(true) + }) + + it('should return true when historyWorkflowData is present', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly(), { + initialStoreState: { + historyWorkflowData: { id: 'run-1', status: 'succeeded' }, + }, + }) + expect(result.current.nodesReadOnly).toBe(true) + }) + + it('should return true when isRestoring is true', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly(), { + initialStoreState: { isRestoring: true }, + }) + expect(result.current.nodesReadOnly).toBe(true) + }) + + it('should return false when none of the conditions are met', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly()) + expect(result.current.nodesReadOnly).toBe(false) + }) + + it('should expose getNodesReadOnly that reads from store state', () => { + const { result, store } = renderWorkflowHook(() => useNodesReadOnly()) + + expect(result.current.getNodesReadOnly()).toBe(false) + + act(() => { + store.setState({ isRestoring: true }) + }) + expect(result.current.getNodesReadOnly()).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// useIsNodeInIteration +// --------------------------------------------------------------------------- + +describe('useIsNodeInIteration', () => { + beforeEach(() => { + rfState.nodes = [ + { id: 'iter-1', position: { x: 0, y: 0 }, data: { type: 'iteration' } }, + { id: 'child-1', position: { x: 10, y: 0 }, parentId: 'iter-1', data: {} }, + { id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} }, + { id: 'outside-1', position: { x: 100, y: 0 }, data: {} }, + ] + }) + + it('should return true when node is a direct child of the iteration', () => { + const { result } = renderHook(() => useIsNodeInIteration('iter-1')) + expect(result.current.isNodeInIteration('child-1')).toBe(true) + }) + + it('should return false for a grandchild (only checks direct parentId)', () => { + const { result } = renderHook(() => useIsNodeInIteration('iter-1')) + expect(result.current.isNodeInIteration('grandchild-1')).toBe(false) + }) + + it('should return false when node is outside the iteration', () => { + const { result } = renderHook(() => useIsNodeInIteration('iter-1')) + expect(result.current.isNodeInIteration('outside-1')).toBe(false) + }) + + it('should return false when node does not exist', () => { + const { result } = renderHook(() => useIsNodeInIteration('iter-1')) + expect(result.current.isNodeInIteration('nonexistent')).toBe(false) + }) + + it('should return false when iteration id has no children', () => { + const { result } = renderHook(() => useIsNodeInIteration('no-such-iter')) + expect(result.current.isNodeInIteration('child-1')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// useIsNodeInLoop +// --------------------------------------------------------------------------- + +describe('useIsNodeInLoop', () => { + beforeEach(() => { + rfState.nodes = [ + { id: 'loop-1', position: { x: 0, y: 0 }, data: { type: 'loop' } }, + { id: 'child-1', position: { x: 10, y: 0 }, parentId: 'loop-1', data: {} }, + { id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} }, + { id: 'outside-1', position: { x: 100, y: 0 }, data: {} }, + ] + }) + + it('should return true when node is a direct child of the loop', () => { + const { result } = renderHook(() => useIsNodeInLoop('loop-1')) + expect(result.current.isNodeInLoop('child-1')).toBe(true) + }) + + it('should return false for a grandchild (only checks direct parentId)', () => { + const { result } = renderHook(() => useIsNodeInLoop('loop-1')) + expect(result.current.isNodeInLoop('grandchild-1')).toBe(false) + }) + + it('should return false when node is outside the loop', () => { + const { result } = renderHook(() => useIsNodeInLoop('loop-1')) + expect(result.current.isNodeInLoop('outside-1')).toBe(false) + }) + + it('should return false when node does not exist', () => { + const { result } = renderHook(() => useIsNodeInLoop('loop-1')) + expect(result.current.isNodeInLoop('nonexistent')).toBe(false) + }) + + it('should return false when loop id has no children', () => { + const { result } = renderHook(() => useIsNodeInLoop('no-such-loop')) + expect(result.current.isNodeInLoop('child-1')).toBe(false) + }) +}) diff --git a/web/app/components/workflow/nodes/human-input/__tests__/human-input.test.tsx b/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx similarity index 100% rename from web/app/components/workflow/nodes/human-input/__tests__/human-input.test.tsx rename to web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx diff --git a/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts similarity index 99% rename from web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts rename to web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts index 4ccd8248b1..4d095ab189 100644 --- a/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts +++ b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts @@ -7,7 +7,7 @@ import { // Mock the getMatchedSchemaType dependency vi.mock('../../_base/components/variable/use-match-schema-type', () => ({ - getMatchedSchemaType: (schema: any) => { + getMatchedSchemaType: (schema: Record | null | undefined) => { // Return schema_type or schemaType if present return schema?.schema_type || schema?.schemaType || undefined }, diff --git a/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.spec.ts similarity index 98% rename from web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts rename to web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.spec.ts index c75ffc0a59..17c6767f3e 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.spec.ts @@ -281,7 +281,7 @@ describe('Form Helpers', () => { describe('Edge cases', () => { it('should handle objects with non-string keys', () => { - const input = { [Symbol('test')]: 'value', regular: 'field' } as any + const input = { [Symbol('test')]: 'value', regular: 'field' } as Record const result = sanitizeFormValues(input) expect(result.regular).toBe('field') @@ -299,7 +299,7 @@ describe('Form Helpers', () => { }) it('should handle circular references in deepSanitizeFormValues gracefully', () => { - const obj: any = { field: 'value' } + const obj: Record = { field: 'value' } obj.circular = obj expect(() => deepSanitizeFormValues(obj)).not.toThrow() diff --git a/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts b/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts index 512eb5b404..145b5d72fe 100644 --- a/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts @@ -1,9 +1,9 @@ import type { ConversationVariable } from '@/app/components/workflow/types' import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } describe('Chat Variable Slice', () => { diff --git a/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts b/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts index 95ed7d3955..a8e53e0b8b 100644 --- a/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts @@ -1,8 +1,8 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } describe('Env Variable Slice', () => { diff --git a/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts b/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts index 225cb6a6c8..4ecbbda092 100644 --- a/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts @@ -1,10 +1,10 @@ import type { NodeWithVar, VarInInspect } from '@/types/workflow' import { BlockEnum, VarType } from '@/app/components/workflow/types' import { VarInInspectType } from '@/types/workflow' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } function makeVar(overrides: Partial = {}): VarInInspect { diff --git a/web/app/components/workflow/store/__tests__/trigger-status.test.ts b/web/app/components/workflow/store/__tests__/trigger-status.spec.ts similarity index 100% rename from web/app/components/workflow/store/__tests__/trigger-status.test.ts rename to web/app/components/workflow/store/__tests__/trigger-status.spec.ts diff --git a/web/app/components/workflow/store/__tests__/version-slice.spec.ts b/web/app/components/workflow/store/__tests__/version-slice.spec.ts index 8d76a62256..d85946354d 100644 --- a/web/app/components/workflow/store/__tests__/version-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/version-slice.spec.ts @@ -1,8 +1,8 @@ import type { VersionHistory } from '@/types/workflow' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } describe('Version Slice', () => { diff --git a/web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts b/web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts index dfbc58e050..b09f8511f2 100644 --- a/web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts @@ -1,8 +1,8 @@ import type { Node } from '@/app/components/workflow/types' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } describe('Workflow Draft Slice', () => { @@ -69,13 +69,20 @@ describe('Workflow Draft Slice', () => { }) describe('debouncedSyncWorkflowDraft', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + it('should be a callable function', () => { const store = createStore() expect(typeof store.getState().debouncedSyncWorkflowDraft).toBe('function') }) it('should debounce the sync call', () => { - vi.useFakeTimers() const store = createStore() const syncFn = vi.fn() @@ -84,12 +91,9 @@ describe('Workflow Draft Slice', () => { vi.advanceTimersByTime(5000) expect(syncFn).toHaveBeenCalledTimes(1) - - vi.useRealTimers() }) it('should flush pending sync via flushPendingSync', () => { - vi.useFakeTimers() const store = createStore() const syncFn = vi.fn() @@ -98,8 +102,6 @@ describe('Workflow Draft Slice', () => { store.getState().flushPendingSync() expect(syncFn).toHaveBeenCalledTimes(1) - - vi.useRealTimers() }) }) }) diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index df94be90b8..c917986953 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -1,18 +1,29 @@ import type { Shape, SliceFromInjection } from '../workflow' -import type { HelpLineHorizontalPosition, HelpLineVerticalPosition } from '@/app/components/workflow/help-line/types' -import type { WorkflowRunningData } from '@/app/components/workflow/types' -import type { FileUploadConfigResponse } from '@/models/common' -import type { VersionHistory } from '@/types/workflow' import { renderHook } from '@testing-library/react' -import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' -import { WorkflowContext } from '../../context' +import { createTestWorkflowStore, renderWorkflowHook } from '../../__tests__/workflow-test-env' import { createWorkflowStore, useStore, useWorkflowStore } from '../workflow' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } +type SetterKey = keyof Shape & `set${string}` +type StateKey = Exclude + +/** + * Verifies a simple setter → state round-trip: + * calling state[setter](value) should update state[stateKey] to equal value. + */ +function testSetter(setter: SetterKey, stateKey: StateKey, value: Shape[StateKey]) { + const store = createStore() + const setFn = store.getState()[setter] as (v: Shape[StateKey]) => void + setFn(value) + expect(store.getState()[stateKey]).toEqual(value) +} + +const emptyIterParallelLogMap = new Map>() + describe('createWorkflowStore', () => { describe('Initial State', () => { it('should create a store with all slices merged', () => { @@ -32,60 +43,23 @@ describe('createWorkflowStore', () => { }) describe('Workflow Slice Setters', () => { - it('should update workflowRunningData', () => { - const store = createStore() - const data: Partial = { result: { status: 'running', inputs_truncated: false, process_data_truncated: false, outputs_truncated: false } } - store.getState().setWorkflowRunningData(data as Parameters[0]) - expect(store.getState().workflowRunningData).toEqual(data) - }) - - it('should update isListening', () => { - const store = createStore() - store.getState().setIsListening(true) - expect(store.getState().isListening).toBe(true) - }) - - it('should update listeningTriggerType', () => { - const store = createStore() - store.getState().setListeningTriggerType(BlockEnum.TriggerWebhook) - expect(store.getState().listeningTriggerType).toBe(BlockEnum.TriggerWebhook) - }) - - it('should update listeningTriggerNodeId', () => { - const store = createStore() - store.getState().setListeningTriggerNodeId('node-abc') - expect(store.getState().listeningTriggerNodeId).toBe('node-abc') - }) - - it('should update listeningTriggerNodeIds', () => { - const store = createStore() - store.getState().setListeningTriggerNodeIds(['n1', 'n2']) - expect(store.getState().listeningTriggerNodeIds).toEqual(['n1', 'n2']) - }) - - it('should update listeningTriggerIsAll', () => { - const store = createStore() - store.getState().setListeningTriggerIsAll(true) - expect(store.getState().listeningTriggerIsAll).toBe(true) - }) - - it('should update clipboardElements', () => { - const store = createStore() - store.getState().setClipboardElements([]) - expect(store.getState().clipboardElements).toEqual([]) - }) - - it('should update selection', () => { - const store = createStore() - const sel = { x1: 0, y1: 0, x2: 100, y2: 100 } - store.getState().setSelection(sel) - expect(store.getState().selection).toEqual(sel) - }) - - it('should update bundleNodeSize', () => { - const store = createStore() - store.getState().setBundleNodeSize({ width: 200, height: 100 }) - expect(store.getState().bundleNodeSize).toEqual({ width: 200, height: 100 }) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['workflowRunningData', 'setWorkflowRunningData', { result: { status: 'running', inputs_truncated: false, process_data_truncated: false, outputs_truncated: false } }], + ['isListening', 'setIsListening', true], + ['listeningTriggerType', 'setListeningTriggerType', BlockEnum.TriggerWebhook], + ['listeningTriggerNodeId', 'setListeningTriggerNodeId', 'node-abc'], + ['listeningTriggerNodeIds', 'setListeningTriggerNodeIds', ['n1', 'n2']], + ['listeningTriggerIsAll', 'setListeningTriggerIsAll', true], + ['clipboardElements', 'setClipboardElements', []], + ['selection', 'setSelection', { x1: 0, y1: 0, x2: 100, y2: 100 }], + ['bundleNodeSize', 'setBundleNodeSize', { width: 200, height: 100 }], + ['mousePosition', 'setMousePosition', { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }], + ['showConfirm', 'setShowConfirm', { title: 'Delete?', onConfirm: vi.fn() }], + ['controlPromptEditorRerenderKey', 'setControlPromptEditorRerenderKey', 42], + ['showImportDSLModal', 'setShowImportDSLModal', true], + ['fileUploadConfig', 'setFileUploadConfig', { batch_count_limit: 5, image_file_batch_limit: 10, single_chunk_attachment_limit: 10, attachment_image_file_size_limit: 2, file_size_limit: 15, file_upload_limit: 5 }], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) it('should persist controlMode to localStorage', () => { @@ -94,180 +68,48 @@ describe('createWorkflowStore', () => { expect(store.getState().controlMode).toBe('pointer') expect(localStorage.setItem).toHaveBeenCalledWith('workflow-operation-mode', 'pointer') }) - - it('should update mousePosition', () => { - const store = createStore() - const pos = { pageX: 10, pageY: 20, elementX: 5, elementY: 15 } - store.getState().setMousePosition(pos) - expect(store.getState().mousePosition).toEqual(pos) - }) - - it('should update showConfirm', () => { - const store = createStore() - const confirm = { title: 'Delete?', onConfirm: vi.fn() } - store.getState().setShowConfirm(confirm) - expect(store.getState().showConfirm).toEqual(confirm) - }) - - it('should update controlPromptEditorRerenderKey', () => { - const store = createStore() - store.getState().setControlPromptEditorRerenderKey(42) - expect(store.getState().controlPromptEditorRerenderKey).toBe(42) - }) - - it('should update showImportDSLModal', () => { - const store = createStore() - store.getState().setShowImportDSLModal(true) - expect(store.getState().showImportDSLModal).toBe(true) - }) - - it('should update fileUploadConfig', () => { - const store = createStore() - const config: FileUploadConfigResponse = { - batch_count_limit: 5, - image_file_batch_limit: 10, - single_chunk_attachment_limit: 10, - attachment_image_file_size_limit: 2, - file_size_limit: 15, - file_upload_limit: 5, - } - store.getState().setFileUploadConfig(config) - expect(store.getState().fileUploadConfig).toEqual(config) - }) }) describe('Node Slice Setters', () => { - it('should update showSingleRunPanel', () => { - const store = createStore() - store.getState().setShowSingleRunPanel(true) - expect(store.getState().showSingleRunPanel).toBe(true) - }) - - it('should update nodeAnimation', () => { - const store = createStore() - store.getState().setNodeAnimation(true) - expect(store.getState().nodeAnimation).toBe(true) - }) - - it('should update candidateNode', () => { - const store = createStore() - store.getState().setCandidateNode(undefined) - expect(store.getState().candidateNode).toBeUndefined() - }) - - it('should update nodeMenu', () => { - const store = createStore() - store.getState().setNodeMenu({ top: 100, left: 200, nodeId: 'n1' }) - expect(store.getState().nodeMenu).toEqual({ top: 100, left: 200, nodeId: 'n1' }) - }) - - it('should update showAssignVariablePopup', () => { - const store = createStore() - store.getState().setShowAssignVariablePopup(undefined) - expect(store.getState().showAssignVariablePopup).toBeUndefined() - }) - - it('should update hoveringAssignVariableGroupId', () => { - const store = createStore() - store.getState().setHoveringAssignVariableGroupId('group-1') - expect(store.getState().hoveringAssignVariableGroupId).toBe('group-1') - }) - - it('should update connectingNodePayload', () => { - const store = createStore() - const payload = { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' } - store.getState().setConnectingNodePayload(payload) - expect(store.getState().connectingNodePayload).toEqual(payload) - }) - - it('should update enteringNodePayload', () => { - const store = createStore() - store.getState().setEnteringNodePayload(undefined) - expect(store.getState().enteringNodePayload).toBeUndefined() - }) - - it('should update iterTimes', () => { - const store = createStore() - store.getState().setIterTimes(5) - expect(store.getState().iterTimes).toBe(5) - }) - - it('should update loopTimes', () => { - const store = createStore() - store.getState().setLoopTimes(10) - expect(store.getState().loopTimes).toBe(10) - }) - - it('should update iterParallelLogMap', () => { - const store = createStore() - const map = new Map>() - store.getState().setIterParallelLogMap(map) - expect(store.getState().iterParallelLogMap).toBe(map) - }) - - it('should update pendingSingleRun', () => { - const store = createStore() - store.getState().setPendingSingleRun({ nodeId: 'n1', action: 'run' }) - expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'n1', action: 'run' }) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['showSingleRunPanel', 'setShowSingleRunPanel', true], + ['nodeAnimation', 'setNodeAnimation', true], + ['candidateNode', 'setCandidateNode', undefined], + ['nodeMenu', 'setNodeMenu', { top: 100, left: 200, nodeId: 'n1' }], + ['showAssignVariablePopup', 'setShowAssignVariablePopup', undefined], + ['hoveringAssignVariableGroupId', 'setHoveringAssignVariableGroupId', 'group-1'], + ['connectingNodePayload', 'setConnectingNodePayload', { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }], + ['enteringNodePayload', 'setEnteringNodePayload', undefined], + ['iterTimes', 'setIterTimes', 5], + ['loopTimes', 'setLoopTimes', 10], + ['iterParallelLogMap', 'setIterParallelLogMap', emptyIterParallelLogMap], + ['pendingSingleRun', 'setPendingSingleRun', { nodeId: 'n1', action: 'run' }], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) describe('Panel Slice Setters', () => { - it('should update showFeaturesPanel', () => { - const store = createStore() - store.getState().setShowFeaturesPanel(true) - expect(store.getState().showFeaturesPanel).toBe(true) - }) - - it('should update showWorkflowVersionHistoryPanel', () => { - const store = createStore() - store.getState().setShowWorkflowVersionHistoryPanel(true) - expect(store.getState().showWorkflowVersionHistoryPanel).toBe(true) - }) - - it('should update showInputsPanel', () => { - const store = createStore() - store.getState().setShowInputsPanel(true) - expect(store.getState().showInputsPanel).toBe(true) - }) - - it('should update showDebugAndPreviewPanel', () => { - const store = createStore() - store.getState().setShowDebugAndPreviewPanel(true) - expect(store.getState().showDebugAndPreviewPanel).toBe(true) - }) - - it('should update panelMenu', () => { - const store = createStore() - store.getState().setPanelMenu({ top: 10, left: 20 }) - expect(store.getState().panelMenu).toEqual({ top: 10, left: 20 }) - }) - - it('should update selectionMenu', () => { - const store = createStore() - store.getState().setSelectionMenu({ top: 50, left: 60 }) - expect(store.getState().selectionMenu).toEqual({ top: 50, left: 60 }) - }) - - it('should update showVariableInspectPanel', () => { - const store = createStore() - store.getState().setShowVariableInspectPanel(true) - expect(store.getState().showVariableInspectPanel).toBe(true) - }) - - it('should update initShowLastRunTab', () => { - const store = createStore() - store.getState().setInitShowLastRunTab(true) - expect(store.getState().initShowLastRunTab).toBe(true) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['showFeaturesPanel', 'setShowFeaturesPanel', true], + ['showWorkflowVersionHistoryPanel', 'setShowWorkflowVersionHistoryPanel', true], + ['showInputsPanel', 'setShowInputsPanel', true], + ['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true], + ['panelMenu', 'setPanelMenu', { top: 10, left: 20 }], + ['selectionMenu', 'setSelectionMenu', { top: 50, left: 60 }], + ['showVariableInspectPanel', 'setShowVariableInspectPanel', true], + ['initShowLastRunTab', 'setInitShowLastRunTab', true], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) describe('Help Line Slice Setters', () => { - it('should update helpLineHorizontal', () => { - const store = createStore() - const pos: HelpLineHorizontalPosition = { top: 100, left: 0, width: 500 } - store.getState().setHelpLineHorizontal(pos) - expect(store.getState().helpLineHorizontal).toEqual(pos) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['helpLineHorizontal', 'setHelpLineHorizontal', { top: 100, left: 0, width: 500 }], + ['helpLineVertical', 'setHelpLineVertical', { top: 0, left: 200, height: 300 }], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) it('should clear helpLineHorizontal', () => { @@ -276,123 +118,50 @@ describe('createWorkflowStore', () => { store.getState().setHelpLineHorizontal(undefined) expect(store.getState().helpLineHorizontal).toBeUndefined() }) - - it('should update helpLineVertical', () => { - const store = createStore() - const pos: HelpLineVerticalPosition = { top: 0, left: 200, height: 300 } - store.getState().setHelpLineVertical(pos) - expect(store.getState().helpLineVertical).toEqual(pos) - }) }) describe('History Slice Setters', () => { - it('should update historyWorkflowData', () => { - const store = createStore() - store.getState().setHistoryWorkflowData({ id: 'run-1', status: 'succeeded' }) - expect(store.getState().historyWorkflowData).toEqual({ id: 'run-1', status: 'succeeded' }) - }) - - it('should update showRunHistory', () => { - const store = createStore() - store.getState().setShowRunHistory(true) - expect(store.getState().showRunHistory).toBe(true) - }) - - it('should update versionHistory', () => { - const store = createStore() - const history: VersionHistory[] = [] - store.getState().setVersionHistory(history) - expect(store.getState().versionHistory).toEqual(history) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['historyWorkflowData', 'setHistoryWorkflowData', { id: 'run-1', status: 'succeeded' }], + ['showRunHistory', 'setShowRunHistory', true], + ['versionHistory', 'setVersionHistory', []], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) describe('Form Slice Setters', () => { - it('should update inputs', () => { - const store = createStore() - store.getState().setInputs({ name: 'test', count: 42 }) - expect(store.getState().inputs).toEqual({ name: 'test', count: 42 }) - }) - - it('should update files', () => { - const store = createStore() - store.getState().setFiles([]) - expect(store.getState().files).toEqual([]) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['inputs', 'setInputs', { name: 'test', count: 42 }], + ['files', 'setFiles', []], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) describe('Tool Slice Setters', () => { - it('should update toolPublished', () => { - const store = createStore() - store.getState().setToolPublished(true) - expect(store.getState().toolPublished).toBe(true) - }) - - it('should update lastPublishedHasUserInput', () => { - const store = createStore() - store.getState().setLastPublishedHasUserInput(true) - expect(store.getState().lastPublishedHasUserInput).toBe(true) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['toolPublished', 'setToolPublished', true], + ['lastPublishedHasUserInput', 'setLastPublishedHasUserInput', true], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) describe('Layout Slice Setters', () => { - it('should update workflowCanvasWidth', () => { - const store = createStore() - store.getState().setWorkflowCanvasWidth(1200) - expect(store.getState().workflowCanvasWidth).toBe(1200) - }) - - it('should update workflowCanvasHeight', () => { - const store = createStore() - store.getState().setWorkflowCanvasHeight(800) - expect(store.getState().workflowCanvasHeight).toBe(800) - }) - - it('should update rightPanelWidth', () => { - const store = createStore() - store.getState().setRightPanelWidth(500) - expect(store.getState().rightPanelWidth).toBe(500) - }) - - it('should update nodePanelWidth', () => { - const store = createStore() - store.getState().setNodePanelWidth(350) - expect(store.getState().nodePanelWidth).toBe(350) - }) - - it('should update previewPanelWidth', () => { - const store = createStore() - store.getState().setPreviewPanelWidth(450) - expect(store.getState().previewPanelWidth).toBe(450) - }) - - it('should update otherPanelWidth', () => { - const store = createStore() - store.getState().setOtherPanelWidth(380) - expect(store.getState().otherPanelWidth).toBe(380) - }) - - it('should update bottomPanelWidth', () => { - const store = createStore() - store.getState().setBottomPanelWidth(600) - expect(store.getState().bottomPanelWidth).toBe(600) - }) - - it('should update bottomPanelHeight', () => { - const store = createStore() - store.getState().setBottomPanelHeight(500) - expect(store.getState().bottomPanelHeight).toBe(500) - }) - - it('should update variableInspectPanelHeight', () => { - const store = createStore() - store.getState().setVariableInspectPanelHeight(250) - expect(store.getState().variableInspectPanelHeight).toBe(250) - }) - - it('should update maximizeCanvas', () => { - const store = createStore() - store.getState().setMaximizeCanvas(true) - expect(store.getState().maximizeCanvas).toBe(true) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['workflowCanvasWidth', 'setWorkflowCanvasWidth', 1200], + ['workflowCanvasHeight', 'setWorkflowCanvasHeight', 800], + ['rightPanelWidth', 'setRightPanelWidth', 500], + ['nodePanelWidth', 'setNodePanelWidth', 350], + ['previewPanelWidth', 'setPreviewPanelWidth', 450], + ['otherPanelWidth', 'setOtherPanelWidth', 380], + ['bottomPanelWidth', 'setBottomPanelWidth', 600], + ['bottomPanelHeight', 'setBottomPanelHeight', 500], + ['variableInspectPanelHeight', 'setVariableInspectPanelHeight', 250], + ['maximizeCanvas', 'setMaximizeCanvas', true], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) @@ -446,13 +215,10 @@ describe('createWorkflowStore', () => { describe('useStore hook', () => { it('should read state via selector when wrapped in WorkflowContext', () => { - const store = createStore() - store.getState().setShowSingleRunPanel(true) - - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(WorkflowContext.Provider, { value: store }, children) - - const { result } = renderHook(() => useStore(s => s.showSingleRunPanel), { wrapper }) + const { result } = renderWorkflowHook( + () => useStore(s => s.showSingleRunPanel), + { initialStoreState: { showSingleRunPanel: true } }, + ) expect(result.current).toBe(true) }) @@ -465,11 +231,7 @@ describe('createWorkflowStore', () => { describe('useWorkflowStore hook', () => { it('should return the store instance when wrapped in WorkflowContext', () => { - const store = createStore() - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(WorkflowContext.Provider, { value: store }, children) - - const { result } = renderHook(() => useWorkflowStore(), { wrapper }) + const { result, store } = renderWorkflowHook(() => useWorkflowStore()) expect(result.current).toBe(store) }) })