From de33561a52cf31442b2defa9901b65518560cc2f Mon Sep 17 00:00:00 2001
From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com>
Date: Tue, 10 Feb 2026 17:00:46 +0800
Subject: [PATCH] test: add comprehensive tests for Human Input Node
functionality (#32191)
---
.../__tests__/human-input.test.tsx | 567 ++++++++++++++++++
1 file changed, 567 insertions(+)
create mode 100644 web/app/components/workflow/nodes/human-input/__tests__/human-input.test.tsx
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.test.tsx
new file mode 100644
index 0000000000..cfb88d3507
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/__tests__/human-input.test.tsx
@@ -0,0 +1,567 @@
+import type { ReactNode } from 'react'
+import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
+import type {
+ Edge,
+ Node,
+} from '@/app/components/workflow/types'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
+import humanInputDefault from '@/app/components/workflow/nodes/human-input/default'
+import HumanInputNode from '@/app/components/workflow/nodes/human-input/node'
+import {
+ DeliveryMethodType,
+ UserActionButtonType,
+} from '@/app/components/workflow/nodes/human-input/types'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { initialNodes, preprocessNodesAndEdges } from '@/app/components/workflow/utils/workflow-init'
+
+// Mock reactflow which is needed by initialNodes and NodeSourceHandle
+vi.mock('reactflow', async () => {
+ const reactflow = await vi.importActual('reactflow')
+ return {
+ ...reactflow,
+ Handle: ({ children }: { children?: ReactNode }) =>
{children}
,
+ }
+})
+
+// Minimal store state mirroring the fields that NodeSourceHandle selects
+const mockStoreState = {
+ shouldAutoOpenStartNodeSelector: false,
+ setShouldAutoOpenStartNodeSelector: vi.fn(),
+ setHasSelectedStartNode: vi.fn(),
+}
+
+// Mock workflow store used by NodeSourceHandle
+// useStore accepts a selector and applies it to the state, so tests break
+// if the component starts selecting fields that aren't provided here.
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: vi.fn((selector?: (s: typeof mockStoreState) => unknown) =>
+ selector ? selector(mockStoreState) : mockStoreState,
+ ),
+ useWorkflowStore: vi.fn(() => ({
+ getState: () => ({
+ getNodes: () => [],
+ }),
+ })),
+}))
+
+// Mock workflow hooks barrel (used by NodeSourceHandle via ../../../hooks)
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesInteractions: () => ({
+ handleNodeAdd: vi.fn(),
+ }),
+ useNodesReadOnly: () => ({
+ getNodesReadOnly: () => false,
+ nodesReadOnly: false,
+ }),
+ useAvailableBlocks: () => ({
+ availableNextBlocks: [],
+ availablePrevBlocks: [],
+ }),
+ useIsChatMode: () => false,
+}))
+
+// ── Factory: Build a realistic human-input node as it would appear after DSL import ──
+const createHumanInputNode = (overrides?: Partial): Node => ({
+ id: 'human-input-1',
+ type: 'custom',
+ position: { x: 400, y: 200 },
+ data: {
+ type: BlockEnum.HumanInput,
+ title: 'Human Input',
+ desc: 'Wait for human input',
+ delivery_methods: [
+ {
+ id: 'dm-1',
+ type: DeliveryMethodType.WebApp,
+ enabled: true,
+ },
+ {
+ id: 'dm-2',
+ type: DeliveryMethodType.Email,
+ enabled: true,
+ config: {
+ recipients: { whole_workspace: false, items: [] },
+ subject: 'Please review',
+ body: 'Please review the form',
+ debug_mode: false,
+ },
+ },
+ ],
+ form_content: '# Review Form\nPlease fill in the details below.',
+ inputs: [
+ {
+ type: 'text-input',
+ output_variable_name: 'review_result',
+ default: { selector: [], type: 'constant' as const, value: '' },
+ },
+ ],
+ user_actions: [
+ {
+ id: 'approve',
+ title: 'Approve',
+ button_style: UserActionButtonType.Primary,
+ },
+ {
+ id: 'reject',
+ title: 'Reject',
+ button_style: UserActionButtonType.Default,
+ },
+ ],
+ timeout: 3,
+ timeout_unit: 'day' as const,
+ ...overrides,
+ } as HumanInputNodeType,
+})
+
+const createStartNode = (): Node => ({
+ id: 'start-1',
+ type: 'custom',
+ position: { x: 100, y: 200 },
+ data: {
+ type: BlockEnum.Start,
+ title: 'Start',
+ desc: '',
+ } as Node['data'],
+})
+
+const createEdge = (source: string, target: string, sourceHandle = 'source', targetHandle = 'target'): Edge => ({
+ id: `${source}-${sourceHandle}-${target}-${targetHandle}`,
+ type: 'custom',
+ source,
+ sourceHandle,
+ target,
+ targetHandle,
+ data: {},
+} as Edge)
+
+describe('DSL Import with Human Input Node', () => {
+ // ── preprocessNodesAndEdges: human-input nodes pass through without error ──
+ describe('preprocessNodesAndEdges', () => {
+ it('should pass through a workflow containing a human-input node unchanged', () => {
+ const humanInputNode = createHumanInputNode()
+ const startNode = createStartNode()
+ const nodes = [startNode, humanInputNode]
+ const edges = [createEdge('start-1', 'human-input-1')]
+
+ const result = preprocessNodesAndEdges(nodes as Node[], edges as Edge[])
+
+ expect(result.nodes).toHaveLength(2)
+ expect(result.edges).toHaveLength(1)
+ expect(result.nodes).toEqual(nodes)
+ expect(result.edges).toEqual(edges)
+ })
+
+ it('should not treat human-input node as an iteration or loop node', () => {
+ const humanInputNode = createHumanInputNode()
+ const nodes = [humanInputNode]
+
+ const result = preprocessNodesAndEdges(nodes as Node[], [])
+
+ // No extra iteration/loop start nodes should be injected
+ expect(result.nodes).toHaveLength(1)
+ expect(result.nodes[0].data.type).toBe(BlockEnum.HumanInput)
+ })
+ })
+
+ // ── initialNodes: human-input nodes are properly initialized ──
+ describe('initialNodes', () => {
+ it('should initialize a human-input node with connected handle IDs', () => {
+ const humanInputNode = createHumanInputNode()
+ const startNode = createStartNode()
+ const nodes = [startNode, humanInputNode]
+ const edges = [createEdge('start-1', 'human-input-1')]
+
+ const result = initialNodes(nodes as Node[], edges as Edge[])
+
+ const processedHumanInput = result.find(n => n.id === 'human-input-1')
+ expect(processedHumanInput).toBeDefined()
+ expect(processedHumanInput!.data.type).toBe(BlockEnum.HumanInput)
+ // initialNodes sets _connectedSourceHandleIds and _connectedTargetHandleIds
+ expect(processedHumanInput!.data._connectedSourceHandleIds).toBeDefined()
+ expect(processedHumanInput!.data._connectedTargetHandleIds).toBeDefined()
+ })
+
+ it('should preserve human-input node data after initialization', () => {
+ const humanInputNode = createHumanInputNode()
+ const nodes = [humanInputNode]
+
+ const result = initialNodes(nodes as Node[], [])
+
+ const processed = result[0]
+ const nodeData = processed.data as HumanInputNodeType
+ expect(nodeData.delivery_methods).toHaveLength(2)
+ expect(nodeData.user_actions).toHaveLength(2)
+ expect(nodeData.form_content).toBe('# Review Form\nPlease fill in the details below.')
+ expect(nodeData.timeout).toBe(3)
+ expect(nodeData.timeout_unit).toBe('day')
+ })
+
+ it('should set node type to custom if not set', () => {
+ const humanInputNode = createHumanInputNode()
+ delete (humanInputNode as Record).type
+
+ const result = initialNodes([humanInputNode] as Node[], [])
+
+ expect(result[0].type).toBe('custom')
+ })
+ })
+
+ // ── Node component: renders without crashing for all data variations ──
+ describe('HumanInputNode Component', () => {
+ it('should render without crashing with full DSL data', () => {
+ const node = createHumanInputNode()
+
+ expect(() => {
+ render(
+ ,
+ )
+ }).not.toThrow()
+ })
+
+ it('should display delivery method labels when methods are present', () => {
+ const node = createHumanInputNode()
+
+ render(
+ ,
+ )
+
+ // Delivery method type labels are rendered in lowercase
+ expect(screen.getByText('webapp')).toBeInTheDocument()
+ expect(screen.getByText('email')).toBeInTheDocument()
+ })
+
+ it('should display user action IDs', () => {
+ const node = createHumanInputNode()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('approve')).toBeInTheDocument()
+ expect(screen.getByText('reject')).toBeInTheDocument()
+ })
+
+ it('should always display Timeout handle', () => {
+ const node = createHumanInputNode()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Timeout')).toBeInTheDocument()
+ })
+
+ it('should render without crashing when delivery_methods is empty', () => {
+ const node = createHumanInputNode({ delivery_methods: [] })
+
+ expect(() => {
+ render(
+ ,
+ )
+ }).not.toThrow()
+
+ // Delivery method section should not be rendered
+ expect(screen.queryByText('webapp')).not.toBeInTheDocument()
+ expect(screen.queryByText('email')).not.toBeInTheDocument()
+ })
+
+ it('should render without crashing when user_actions is empty', () => {
+ const node = createHumanInputNode({ user_actions: [] })
+
+ expect(() => {
+ render(
+ ,
+ )
+ }).not.toThrow()
+
+ // Timeout handle should still exist
+ expect(screen.getByText('Timeout')).toBeInTheDocument()
+ })
+
+ it('should render without crashing when both delivery_methods and user_actions are empty', () => {
+ const node = createHumanInputNode({
+ delivery_methods: [],
+ user_actions: [],
+ form_content: '',
+ inputs: [],
+ })
+
+ expect(() => {
+ render(
+ ,
+ )
+ }).not.toThrow()
+ })
+
+ it('should render with only webapp delivery method', () => {
+ const node = createHumanInputNode({
+ delivery_methods: [
+ { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
+ ],
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('webapp')).toBeInTheDocument()
+ expect(screen.queryByText('email')).not.toBeInTheDocument()
+ })
+
+ it('should render with multiple user actions', () => {
+ const node = createHumanInputNode({
+ user_actions: [
+ { id: 'action_1', title: 'Approve', button_style: UserActionButtonType.Primary },
+ { id: 'action_2', title: 'Reject', button_style: UserActionButtonType.Default },
+ { id: 'action_3', title: 'Escalate', button_style: UserActionButtonType.Accent },
+ ],
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('action_1')).toBeInTheDocument()
+ expect(screen.getByText('action_2')).toBeInTheDocument()
+ expect(screen.getByText('action_3')).toBeInTheDocument()
+ })
+ })
+
+ // ── Node registration: human-input is included in the workflow node registry ──
+ // Verify via WORKFLOW_COMMON_NODES (lightweight metadata-only imports) instead
+ // of NodeComponentMap/PanelComponentMap which pull in every node's heavy UI deps.
+ describe('Node Registration', () => {
+ it('should have HumanInput included in WORKFLOW_COMMON_NODES', () => {
+ const entry = WORKFLOW_COMMON_NODES.find(
+ n => n.metaData.type === BlockEnum.HumanInput,
+ )
+ expect(entry).toBeDefined()
+ })
+ })
+
+ // ── Default config & validation ──
+ describe('HumanInput Default Configuration', () => {
+ it('should provide default values for a new human-input node', () => {
+ const defaultValue = humanInputDefault.defaultValue
+
+ expect(defaultValue.delivery_methods).toEqual([])
+ expect(defaultValue.user_actions).toEqual([])
+ expect(defaultValue.form_content).toBe('')
+ expect(defaultValue.inputs).toEqual([])
+ expect(defaultValue.timeout).toBe(3)
+ expect(defaultValue.timeout_unit).toBe('day')
+ })
+
+ it('should validate that delivery methods are required', () => {
+ const t = (key: string) => key
+ const payload = {
+ ...humanInputDefault.defaultValue,
+ delivery_methods: [],
+ } as HumanInputNodeType
+
+ const result = humanInputDefault.checkValid(payload, t)
+
+ expect(result.isValid).toBe(false)
+ expect(result.errorMessage).toBeTruthy()
+ })
+
+ it('should validate that at least one delivery method is enabled', () => {
+ const t = (key: string) => key
+ const payload = {
+ ...humanInputDefault.defaultValue,
+ delivery_methods: [
+ { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: false },
+ ],
+ user_actions: [
+ { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
+ ],
+ } as HumanInputNodeType
+
+ const result = humanInputDefault.checkValid(payload, t)
+
+ expect(result.isValid).toBe(false)
+ })
+
+ it('should validate that user actions are required', () => {
+ const t = (key: string) => key
+ const payload = {
+ ...humanInputDefault.defaultValue,
+ delivery_methods: [
+ { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
+ ],
+ user_actions: [],
+ } as HumanInputNodeType
+
+ const result = humanInputDefault.checkValid(payload, t)
+
+ expect(result.isValid).toBe(false)
+ })
+
+ it('should validate that user action IDs are not duplicated', () => {
+ const t = (key: string) => key
+ const payload = {
+ ...humanInputDefault.defaultValue,
+ delivery_methods: [
+ { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
+ ],
+ user_actions: [
+ { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
+ { id: 'approve', title: 'Also Approve', button_style: UserActionButtonType.Default },
+ ],
+ } as HumanInputNodeType
+
+ const result = humanInputDefault.checkValid(payload, t)
+
+ expect(result.isValid).toBe(false)
+ })
+
+ it('should pass validation with correct configuration', () => {
+ const t = (key: string) => key
+ const payload = {
+ ...humanInputDefault.defaultValue,
+ delivery_methods: [
+ { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
+ ],
+ user_actions: [
+ { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
+ { id: 'reject', title: 'Reject', button_style: UserActionButtonType.Default },
+ ],
+ } as HumanInputNodeType
+
+ const result = humanInputDefault.checkValid(payload, t)
+
+ expect(result.isValid).toBe(true)
+ expect(result.errorMessage).toBe('')
+ })
+ })
+
+ // ── Output variables generation ──
+ describe('HumanInput Output Variables', () => {
+ it('should generate output variables from form inputs', () => {
+ const payload = {
+ ...humanInputDefault.defaultValue,
+ inputs: [
+ { type: 'text-input', output_variable_name: 'review_result', default: { selector: [], type: 'constant' as const, value: '' } },
+ { type: 'text-input', output_variable_name: 'comment', default: { selector: [], type: 'constant' as const, value: '' } },
+ ],
+ } as HumanInputNodeType
+
+ const outputVars = humanInputDefault.getOutputVars!(payload, {}, [])
+
+ expect(outputVars).toEqual([
+ { variable: 'review_result', type: 'string' },
+ { variable: 'comment', type: 'string' },
+ ])
+ })
+
+ it('should return empty output variables when no form inputs exist', () => {
+ const payload = {
+ ...humanInputDefault.defaultValue,
+ inputs: [],
+ } as HumanInputNodeType
+
+ const outputVars = humanInputDefault.getOutputVars!(payload, {}, [])
+
+ expect(outputVars).toEqual([])
+ })
+ })
+
+ // ── Full DSL import simulation: start → human-input → end ──
+ describe('Full Workflow with Human Input Node', () => {
+ it('should process a start → human-input → end workflow without errors', () => {
+ const startNode = createStartNode()
+ const humanInputNode = createHumanInputNode()
+ const endNode: Node = {
+ id: 'end-1',
+ type: 'custom',
+ position: { x: 700, y: 200 },
+ data: {
+ type: BlockEnum.End,
+ title: 'End',
+ desc: '',
+ outputs: [],
+ } as Node['data'],
+ }
+
+ const nodes = [startNode, humanInputNode, endNode]
+ const edges = [
+ createEdge('start-1', 'human-input-1'),
+ createEdge('human-input-1', 'end-1', 'approve', 'target'),
+ ]
+
+ const processed = preprocessNodesAndEdges(nodes as Node[], edges as Edge[])
+ expect(processed.nodes).toHaveLength(3)
+ expect(processed.edges).toHaveLength(2)
+
+ const initialized = initialNodes(nodes as Node[], edges as Edge[])
+ expect(initialized).toHaveLength(3)
+
+ // All node types should be preserved
+ const types = initialized.map(n => n.data.type)
+ expect(types).toContain(BlockEnum.Start)
+ expect(types).toContain(BlockEnum.HumanInput)
+ expect(types).toContain(BlockEnum.End)
+ })
+
+ it('should handle multiple branches from human-input user actions', () => {
+ const startNode = createStartNode()
+ const humanInputNode = createHumanInputNode()
+ const approveEndNode: Node = {
+ id: 'approve-end',
+ type: 'custom',
+ position: { x: 700, y: 100 },
+ data: { type: BlockEnum.End, title: 'Approve End', desc: '', outputs: [] } as Node['data'],
+ }
+ const rejectEndNode: Node = {
+ id: 'reject-end',
+ type: 'custom',
+ position: { x: 700, y: 300 },
+ data: { type: BlockEnum.End, title: 'Reject End', desc: '', outputs: [] } as Node['data'],
+ }
+
+ const nodes = [startNode, humanInputNode, approveEndNode, rejectEndNode]
+ const edges = [
+ createEdge('start-1', 'human-input-1'),
+ createEdge('human-input-1', 'approve-end', 'approve', 'target'),
+ createEdge('human-input-1', 'reject-end', 'reject', 'target'),
+ ]
+
+ const initialized = initialNodes(nodes as Node[], edges as Edge[])
+ expect(initialized).toHaveLength(4)
+
+ // Human input node should still have correct data
+ const hiNode = initialized.find(n => n.id === 'human-input-1')!
+ expect((hiNode.data as HumanInputNodeType).user_actions).toHaveLength(2)
+ expect((hiNode.data as HumanInputNodeType).delivery_methods).toHaveLength(2)
+ })
+ })
+})