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