mirror of
https://github.com/langgenius/dify.git
synced 2026-03-10 11:10:19 +08:00
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:
parent
5a3348ec8d
commit
5385ec3023
@ -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 }
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
143
web/app/components/workflow/__tests__/reactflow-mock-state.ts
Normal file
143
web/app/components/workflow/__tests__/reactflow-mock-state.ts
Normal 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 }
|
||||
@ -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 ?? [] })),
|
||||
}
|
||||
}
|
||||
@ -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} />
|
||||
195
web/app/components/workflow/__tests__/workflow-test-env.tsx
Normal file
195
web/app/components/workflow/__tests__/workflow-test-env.tsx
Normal 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: () => {},
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
194
web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts
Normal file
194
web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
234
web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts
Normal file
234
web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
},
|
||||
@ -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()
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user