test(workflow): add comprehensive hooks unit tests and refactor test infrastructure (Part 3) (#32958)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star 2026-03-04 17:24:50 +08:00 committed by GitHub
parent 5a3348ec8d
commit 5385ec3023
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 3615 additions and 729 deletions

View File

@ -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>,
): 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>,
): 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>,
): 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 }

View File

@ -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<string, unknown> = {}) {
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,
}
}

View File

@ -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<string, unknown> = {}) {
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<string, unknown>) => React.createElement('div', { 'data-testid': 'handle', ...props }),
BaseEdge: (props: Record<string, unknown>) => 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,
}
}

View File

@ -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<string, unknown> = {}) {
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,
}
}

View File

@ -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<string, unknown>
}
type MockEdge = {
id: string
source: string
target: string
sourceHandle?: string
data: Record<string, unknown>
}
type ReactFlowMockState = {
nodes: MockNode[]
edges: MockEdge[]
transform: [number, number, number]
setViewport: ReturnType<typeof vi.fn>
setNodes: ReturnType<typeof vi.fn>
setEdges: ReturnType<typeof vi.fn>
}
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<string, unknown>) => React.createElement('div', props),
BaseEdge: (props: Record<string, unknown>) => 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 }

View File

@ -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 ?? [] })),
}
}

View File

@ -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<string, string> }) =>
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<string, string> }) =>
mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled',
['test-node', nodeType], // Dependencies should match implementation
[nodeType],
)
const status = useTriggerStatusStore(triggerStatusSelector)
return <div data-testid="test-component" data-status={status} />

View File

@ -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<string, unknown> = {}) {
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<typeof createWorkflowStore>
type HooksStore = ReturnType<typeof createHooksStore>
export function createTestWorkflowStore(initialState?: Partial<Shape>): WorkflowStore {
const store = createWorkflowStore({})
if (initialState)
store.setState(initialState)
return store
}
export function createTestHooksStore(props?: Partial<HooksStoreShape>): HooksStore {
return createHooksStore(props ?? {})
}
// ---------------------------------------------------------------------------
// renderWorkflowHook — composable hook renderer
// ---------------------------------------------------------------------------
type HistoryStoreConfig = {
nodes?: Node[]
edges?: Edge[]
}
type WorkflowTestOptions<P> = Omit<RenderHookOptions<P>, 'wrapper'> & {
initialStoreState?: Partial<Shape>
hooksStoreProps?: Partial<HooksStoreShape>
historyStore?: HistoryStoreConfig
}
type WorkflowTestResult<R, P> = RenderHookResult<R, P> & {
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<R, P = undefined>(
hook: (props: P) => R,
options?: WorkflowTestOptions<P>,
): WorkflowTestResult<R, P> {
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<HistState>(
(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: () => {},
}
}

View File

@ -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()
})
})

View File

@ -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)
})
})
})

View File

@ -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<string, { checkValid: CheckValidFn, metaData: { isStart: boolean, isRequired: boolean } }> = {}
vi.mock('../use-nodes-meta-data', () => ({
useNodesMetaData: () => ({
nodes: [],
nodesMap: mockNodesMap,
}),
}))
vi.mock('../use-nodes-available-var-list', () => ({
default: (nodes: Node[]) => {
const map: Record<string, { availableVars: never[] }> = {}
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)
})
})

View File

@ -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<string, unknown>).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<string, unknown>).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()
})
})
})

View File

@ -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<string, unknown> & { 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()
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})

View File

@ -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<string, unknown> }>, 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)
})
})

View File

@ -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<string, unknown>) => [] as unknown[]),
mockGetVarType: vi.fn((_args: Record<string, unknown>) => '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')
})
})

View File

@ -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)
})
})

View File

@ -7,7 +7,7 @@ import {
// Mock the getMatchedSchemaType dependency
vi.mock('../../_base/components/variable/use-match-schema-type', () => ({
getMatchedSchemaType: (schema: any) => {
getMatchedSchemaType: (schema: Record<string, unknown> | null | undefined) => {
// Return schema_type or schemaType if present
return schema?.schema_type || schema?.schemaType || undefined
},

View File

@ -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<string, unknown>
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<string, unknown> = { field: 'value' }
obj.circular = obj
expect(() => deepSanitizeFormValues(obj)).not.toThrow()

View File

@ -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', () => {

View File

@ -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', () => {

View File

@ -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> = {}): VarInInspect {

View File

@ -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', () => {

View File

@ -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()
})
})
})

View File

@ -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<keyof Shape, SetterKey>
/**
* 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<string, Map<string, never[]>>()
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<WorkflowRunningData> = { result: { status: 'running', inputs_truncated: false, process_data_truncated: false, outputs_truncated: false } }
store.getState().setWorkflowRunningData(data as Parameters<Shape['setWorkflowRunningData']>[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<string, Map<string, never[]>>()
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)
})
})