From d4f41acfc2bbf3450159edf005faf5acf3c75ee9 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Fri, 30 Oct 2026 03:27:53 +0000 Subject: [PATCH] test(web): exercise HITL graph and configuration behavior through real owners (#45108) --- .../model-list.dynamic-import.spec.tsx | 77 +-- .../use-insert-snippet.hitl-v2.spec.tsx | 270 +++----- .../use-nodes-interactions.hitl-v2.spec.ts | 624 +++++------------- .../form-input-item.branches.spec.tsx | 11 +- 4 files changed, 267 insertions(+), 715 deletions(-) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.dynamic-import.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.dynamic-import.spec.tsx index d6a1a253fe7..fe472dc87f3 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.dynamic-import.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.dynamic-import.spec.tsx @@ -1,75 +1,22 @@ -import type { ModelItem, ModelProvider } from '../../declarations' -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' -import ModelList from '../model-list' - -const { mockToastError } = vi.hoisted(() => ({ - mockToastError: vi.fn(), -})) - -vi.mock('@/context/permission-state', async () => { - const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') - return createPermissionStateModuleMock(() => ({ - workspacePermissionKeys: ['plugin.model_config'], - })) -}) - -vi.mock('../../hooks', () => ({ - useLazyModelProviderDetail: () => ({ - loadProviderDetail: vi.fn(), - }), -})) - -vi.mock('@/app/notifications', () => ({ - toast: { - error: mockToastError, - }, -})) +import { act, screen, waitFor } from '@testing-library/react' +import { toast } from '@/app/notifications' +import { dialogName, renderModelList } from './model-list-test-env' +// Keep the model row and its configuration action real; only module loading fails. vi.mock('../model-load-balancing-modal', () => { throw new Error('Failed to load model load balancing modal') }) -vi.mock('../model-list-item', () => ({ - default: ({ - model, - onModifyLoadBalancing, - }: { - model: ModelItem - onModifyLoadBalancing: (model: ModelItem) => void - }) => ( - - ), -})) +afterEach(() => act(() => toast.dismiss())) -describe('ModelList dynamic import failure', () => { - const provider = { - provider: 'test-provider', - configurate_methods: [], - } as unknown as ModelProvider - const model = { - model: 'gpt-4', - model_type: 'llm', - fetch_from: 'system', - } as unknown as ModelItem +it('clears the loading dialog and reports an error when the modal module cannot load', async () => { + const { user } = renderModelList() + await user.click(screen.getAllByRole('button', { name: 'common.operation.config' })[0]!) - beforeEach(() => { - vi.clearAllMocks() - }) - - it('clears the loading dialog and reports an error when the modal module cannot load', async () => { - render() - - const user = userEvent.setup() - await user.click(screen.getByRole('button', { name: 'gpt-4' })) - - await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith('common.api.actionFailed') - }) + expect(await screen.findByText('common.api.actionFailed')).toBeVisible() + await waitFor(() => { expect(screen.queryByRole('status')).not.toBeInTheDocument() - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument() }) + expect(screen.getAllByRole('button', { name: 'common.operation.config' })[0]).toBeEnabled() }) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.hitl-v2.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.hitl-v2.spec.tsx index 5729e8c3a29..81ea5c53f88 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.hitl-v2.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.hitl-v2.spec.tsx @@ -1,213 +1,105 @@ import type { SnippetWorkflowResponse } from '@dify/contracts/api/console/snippets/types.gen' -import type { QueryClient } from '@tanstack/react-query' -import { act, renderHook } from '@testing-library/react' +import type { Node } from '../../../types' +import { act } from '@testing-library/react' +import { useStoreApi } from 'reactflow' import { consoleQuery } from '@/service/console' -import { createQueryClientWrapper } from '@/test/console/query-client' import { createTestQueryClient } from '@/test/query-client' +import { createNode } from '../../../__tests__/fixtures' +import { renderWorkflowCanvasHook } from '../../../__tests__/workflow-test-env' +import HumanInputV2Default from '../../../nodes/human-input-v2/default' import HumanInputDefault from '../../../nodes/human-input/default' -import { createDefaultFormInputByType } from '../../../nodes/human-input/types' -import { InputVarType } from '../../../types' +import { BlockEnum } from '../../../types' import { useInsertSnippet } from '../use-insert-snippet' -const mockRequest = vi.hoisted(() => vi.fn()) const releaseGate = vi.hoisted(() => ({ enabled: true })) - vi.mock('@/config', async (importOriginal) => ({ ...(await importOriginal()), get ENABLE_HITL_IM() { return releaseGate.enabled }, })) -const mockHandleSyncWorkflowDraft = vi.fn() -const mockSaveStateToHistory = vi.fn() -const mockToastError = vi.fn() -const mockToastWarning = vi.fn() -const mockGetNodes = vi.fn() -const mockSetNodes = vi.fn() -const mockSetEdges = vi.fn() -const mockIncrementSnippetUseCount = vi.fn() -let mockEdges: unknown[] = [{ id: 'existing-edge', source: 'old', target: 'old-2' }] - -vi.mock('@/service/base', () => ({ - request: (...args: unknown[]) => mockRequest(...args), - sseGeneratorPost: vi.fn(), -})) - -vi.mock('reactflow', () => ({ - useStoreApi: () => ({ - getState: () => ({ - getNodes: mockGetNodes, - setNodes: mockSetNodes, - edges: mockEdges, - setEdges: mockSetEdges, - }), - }), -})) - -vi.mock('../../../hooks/use-nodes-sync-draft', () => ({ - useNodesSyncDraft: () => ({ - handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, - }), -})) - -vi.mock('../../../hooks/use-workflow-history', () => ({ - useWorkflowHistory: () => ({ - saveStateToHistory: mockSaveStateToHistory, - }), - WorkflowHistoryEvent: { - NodePaste: 'NodePaste', - }, -})) - -vi.mock('@/app/notifications', () => ({ - toast: { - error: (...args: unknown[]) => mockToastError(...args), - warning: (...args: unknown[]) => mockToastWarning(...args), - }, -})) +// Usage accounting is a service boundary; graph insertion and its stores stay real. vi.mock('@/service/use-snippets', () => ({ - useIncrementSnippetUseCountMutation: () => ({ - mutate: mockIncrementSnippetUseCount, - }), + useIncrementSnippetUseCountMutation: () => ({ mutate: vi.fn() }), })) -type PublishedWorkflow = Pick - -const seedPublishedWorkflow = (queryClient: QueryClient, workflow: PublishedWorkflow) => { - const queryOptions = consoleQuery.snippets.bySnippetId.workflows.publish.get.queryOptions({ - input: { - params: { snippet_id: 'snippet-1' }, +const approval = (id: string, version?: '2') => + createNode({ + id, + data: { + ...(version ? HumanInputV2Default : HumanInputDefault).defaultValue, + type: BlockEnum.HumanInput, + title: 'Approval', }, }) - - queryClient.setQueryData(queryOptions.queryKey, workflow) +function setup(nodes: Node[], insertedNode: Node) { + const queryClient = createTestQueryClient() + queryClient.setQueryData>( + consoleQuery.snippets.bySnippetId.workflows.publish.get.queryKey({ + input: { params: { snippet_id: 'snippet-1' } }, + }), + { graph: { nodes: [insertedNode], edges: [] } }, + ) + return renderWorkflowCanvasHook( + () => ({ + ...useInsertSnippet(), + canvas: useStoreApi(), + }), + { + nodes, + edges: [], + queryClient, + historyStore: { nodes, edges: [] }, + hooksStoreProps: { doSyncWorkflowDraft: async () => null }, + }, + ) } -describe('useInsertSnippet', () => { - let queryClient: QueryClient - const renderUseInsertSnippet = () => - renderHook(() => useInsertSnippet(), { - wrapper: createQueryClientWrapper(queryClient), - }) - - beforeEach(() => { - vi.clearAllMocks() - releaseGate.enabled = true - queryClient = createTestQueryClient() - mockEdges = [{ id: 'existing-edge', source: 'old', target: 'old-2' }] - mockGetNodes.mockReturnValue([ - { - id: 'existing-node', - position: { x: 0, y: 0 }, - data: { selected: true }, - }, - ]) - }) - - afterEach(() => { - queryClient.clear() +beforeEach(() => { + releaseGate.enabled = true + vi.useFakeTimers() +}) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +it.each([undefined, '2'] as const)( + 'allows only legacy snippet approvals (version %s) when the release gate is off', + async (version) => { + releaseGate.enabled = false + const existing = version ? createNode({ id: 'existing-code' }) : approval('existing-legacy') + const inserted = approval('snippet-approval', version) + const { result } = setup([existing], inserted) + const before = result.current.canvas.getState().getNodes() + + await act(async () => result.current.handleInsertSnippet('snippet-1')) + + const nodes = result.current.canvas.getState().getNodes() + if (version) { + expect(nodes).toEqual(before) + } else { + expect(nodes).toHaveLength(2) + expect(nodes[0]).toMatchObject({ id: existing.id, data: existing.data }) + expect(nodes[1]?.id).not.toBe(inserted.id) + expect(nodes[1]?.data).toMatchObject(inserted.data) + } + expect(result.current.canvas.getState().edges).toEqual([]) + }, +) + +it('preserves the graph when a legacy draft blocks a Human Input v2 snippet', async () => { + const { result } = setup([approval('legacy-human-input')], approval('snippet-human-input', '2')) + const before = result.current.canvas.getState().getNodes() + + let inserted: boolean | undefined + await act(async () => { + inserted = await result.current.handleInsertSnippet('snippet-1') + await vi.advanceTimersByTimeAsync(600) }) - - describe('Insert Flow', () => { - it.each([undefined, '2'])( - 'allows only legacy snippet approvals (version %s) when the release gate is off', - async (version) => { - releaseGate.enabled = false - const data = { - ...HumanInputDefault.defaultValue, - type: 'human-input', - title: 'Approval', - ...(version ? { version } : {}), - } - mockGetNodes.mockReturnValue([ - { - id: 'existing-legacy', - type: 'custom', - position: { x: 0, y: 0 }, - data: version - ? { type: 'code', title: 'Existing code' } - : { ...HumanInputDefault.defaultValue, type: 'human-input' }, - }, - ]) - seedPublishedWorkflow(queryClient, { - graph: { - nodes: [{ id: 'snippet-approval', type: 'custom', position: { x: 10, y: 20 }, data }], - edges: [], - }, - }) - const { result } = renderUseInsertSnippet() - - await act(async () => result.current.handleInsertSnippet('snippet-1')) - - if (version) { - expect(mockToastWarning).toHaveBeenCalledWith('workflow.nodes.humanInputV2.disabled') - expect(mockSetNodes).not.toHaveBeenCalled() - expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() - } else { - expect(mockSetNodes).toHaveBeenCalledTimes(1) - expect(mockSetNodes.mock.lastCall![0]).toHaveLength(2) - expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1) - } - }, - ) - it('keeps legacy-only snippets usable when existing legacy fields cannot migrate', async () => { - const data = { - ...HumanInputDefault.defaultValue, - inputs: [createDefaultFormInputByType(InputVarType.select, 'decision')], - } - mockGetNodes.mockReturnValue([ - { id: 'legacy-human-input', type: 'custom', position: { x: 0, y: 0 }, data }, - ]) - seedPublishedWorkflow(queryClient, { - graph: { - nodes: [{ id: 'snippet-human-input', type: 'custom', position: { x: 10, y: 20 }, data }], - edges: [], - }, - }) - const { result } = renderUseInsertSnippet() - - await act(async () => result.current.handleInsertSnippet('snippet-1')) - - expect(mockToastWarning).not.toHaveBeenCalled() - expect(mockSetNodes).toHaveBeenCalledTimes(1) - expect(mockSetNodes.mock.lastCall![0]).toHaveLength(2) - expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1) - }) - - it('blocks snippets containing Human Input while the draft has a legacy node', async () => { - mockGetNodes.mockReturnValue([ - { - id: 'legacy-human-input', - position: { x: 0, y: 0 }, - data: { type: 'human-input' }, - }, - ]) - seedPublishedWorkflow(queryClient, { - graph: { - nodes: [ - { - id: 'snippet-human-input', - position: { x: 10, y: 20 }, - data: { type: 'human-input', version: '2' }, - }, - ], - edges: [], - }, - }) - const { result } = renderUseInsertSnippet() - - await act(async () => { - expect(await result.current.handleInsertSnippet('snippet-1')).toBe(false) - }) - - expect(mockToastWarning).toHaveBeenCalledWith( - 'workflow.nodes.humanInputMigration.disabledReason', - ) - expect(mockSetNodes).not.toHaveBeenCalled() - expect(mockSetEdges).not.toHaveBeenCalled() - expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() - }) - }) + expect(inserted).toBe(false) + expect(result.current.canvas.getState().getNodes()).toEqual(before) + expect(result.current.canvas.getState().edges).toEqual([]) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.hitl-v2.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.hitl-v2.spec.ts index e65c3590e93..8d873753c42 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.hitl-v2.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.hitl-v2.spec.ts @@ -1,480 +1,200 @@ +import type { ReactNode } from 'react' import type { HumanInputV2NodeType } from '../../nodes/human-input-v2/types' -import type { Edge, Node } from '../../types' +import type { Node } from '../../types' import { act } from '@testing-library/react' -import { createEdge, createNode } from '../../__tests__/fixtures' -import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' -import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { createElement, Fragment } from 'react' +import { useStoreApi } from 'reactflow' +import { seedModelProviderQueries } from '@/test/console/model-provider' +import { createConsoleQueryWrapper, seedAppDslVersion } from '@/test/console/query-data' +import { CanvasNodesCatalogProbe } from '../../__tests__/canvas-nodes-catalog-probe' +import { createNode } from '../../__tests__/fixtures' +import { renderWorkflowCanvasHook } from '../../__tests__/workflow-test-env' +import HumanInputV2Default from '../../nodes/human-input-v2/default' +import HumanInputDefault from '../../nodes/human-input/default' import { BlockEnum } from '../../types' +import { writeWorkflowClipboard } from '../../utils/clipboard' import { useNodesInteractions } from '../use-nodes-interactions' -const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn()) -const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) -const mockUndo = vi.hoisted(() => vi.fn()) -const mockRedo = vi.hoisted(() => vi.fn()) -const mockHandleNodeIterationChildrenCopy = vi.hoisted(() => - vi.fn(() => ({ - copyChildren: [], - newIdMapping: {}, - })), -) -const mockHandleNodeLoopChildrenCopy = vi.hoisted(() => - vi.fn(() => ({ - copyChildren: [], - newIdMapping: {}, - })), -) -const mockCreateInlineAgentBinding = vi.hoisted(() => vi.fn()) -const runtimeNodesMetaDataMap = vi.hoisted(() => ({ - value: {} as Record, -})) - -const runtimeState = vi.hoisted(() => ({ - isChatMode: false, - nodesReadOnly: false, - workflowReadOnly: false, -})) - vi.mock('@/config', async (importOriginal) => ({ ...(await importOriginal()), ENABLE_HITL_IM: true, })) -let currentNodes: Node[] = [] -let currentEdges: Edge[] = [] - -vi.mock('reactflow', async () => - (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), -) - -vi.mock('../use-workflow', () => ({ - useIsChatMode: () => runtimeState.isChatMode, - useWorkflow: () => ({ - getAfterNodesInSameBranch: () => [], - }), - useNodesReadOnly: () => ({ - getNodesReadOnly: () => runtimeState.nodesReadOnly, - }), - useWorkflowReadOnly: () => ({ - getWorkflowReadOnly: () => runtimeState.workflowReadOnly, - }), -})) - -vi.mock('../use-helpline', () => ({ - useHelpline: () => ({ - handleSetHelpline: () => ({ - showHorizontalHelpLineNodes: [], - showVerticalHelpLineNodes: [], - }), - }), -})) - -vi.mock('../use-nodes-meta-data', () => ({ - useNodesMetaData: () => ({ - nodesMap: runtimeNodesMetaDataMap.value, - }), -})) - -vi.mock('../use-nodes-sync-draft', () => ({ - useNodesSyncDraft: () => ({ - handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, - }), -})) - -vi.mock('../use-auto-generate-webhook-url', () => ({ - useAutoGenerateWebhookUrl: () => vi.fn(), -})) - -vi.mock('../use-inspect-vars-crud', () => ({ - default: () => ({ - deleteNodeInspectorVars: vi.fn(), - }), -})) - -vi.mock('../../nodes/agent-v2/hooks', () => ({ - useCreateInlineAgentBinding: () => ({ - createInlineAgentBinding: mockCreateInlineAgentBinding, - }), -})) - -vi.mock('../../nodes/iteration/use-interactions', () => ({ - useNodeIterationInteractions: () => ({ - handleNodeIterationChildDrag: () => ({ restrictPosition: {} }), - handleNodeIterationChildrenCopy: mockHandleNodeIterationChildrenCopy, - }), -})) - -vi.mock('../../nodes/loop/use-interactions', () => ({ - useNodeLoopInteractions: () => ({ - handleNodeLoopChildDrag: () => ({ restrictPosition: {} }), - handleNodeLoopChildrenCopy: mockHandleNodeLoopChildrenCopy, - }), -})) - -vi.mock('../use-workflow-history', async (importOriginal) => ({ - ...(await importOriginal()), - useWorkflowHistory: () => ({ - saveStateToHistory: mockSaveStateToHistory, - undo: mockUndo, - redo: mockRedo, - }), -})) - -describe('useNodesInteractions', () => { - beforeEach(() => { - vi.clearAllMocks() - resetReactFlowMockState() - runtimeState.isChatMode = false - runtimeState.nodesReadOnly = false - runtimeState.workflowReadOnly = false - mockCreateInlineAgentBinding.mockImplementation( - ( - _nodeId: string, - options?: { - onSuccess?: (binding: { - binding_type: 'inline_agent' - agent_id: string - current_snapshot_id: string - }) => void - }, - ) => { - options?.onSuccess?.({ - binding_type: 'inline_agent', - agent_id: 'inline-agent-1', - current_snapshot_id: 'inline-snapshot-1', - }) - }, - ) - currentNodes = [ - createNode({ - id: 'node-1', - position: { x: 10, y: 20 }, - data: { - type: BlockEnum.Code, - title: 'Code', - desc: '', - }, - }), - ] - currentEdges = [ - createEdge({ - id: 'edge-1', - source: 'node-1', - target: 'node-2', - }), - ] - rfState.nodes = currentNodes as unknown as typeof rfState.nodes - rfState.edges = currentEdges as unknown as typeof rfState.edges - runtimeNodesMetaDataMap.value = {} +const legacyNode = () => + createNode({ + id: 'legacy-human-input', + data: { + ...HumanInputDefault.defaultValue, + type: BlockEnum.HumanInput, + title: 'Legacy approval', + }, }) - - it.each([undefined, BlockEnum.Iteration, BlockEnum.Loop])( - 'creates Human Input v2 with persisted type in container %s', - (containerType) => { - const parentId = containerType ? 'container-node' : undefined - currentNodes = [ - createNode({ - id: 'node-1', - parentId, - width: 100, - data: { type: BlockEnum.Code, title: 'Code', desc: '' }, - }), - ] - if (containerType) { - currentNodes.unshift( - createNode({ - id: parentId, - width: 500, - height: 400, - data: { type: containerType, title: 'Container', desc: '' }, - }), - ) - } - rfState.nodes = currentNodes as unknown as typeof rfState.nodes - rfState.edges = [] - rfState.setNodes.mockImplementation((nextNodes) => { - rfState.nodes = nextNodes - }) - runtimeNodesMetaDataMap.value = { - [BlockEnum.HumanInputV2]: { - defaultValue: { - type: BlockEnum.HumanInput, - title: 'Human Input v2', - desc: '', - version: '2', - recipients_spec: [], - message_template: { subject: '', body: '' }, - debug_mode: { enabled: false, channels: [] }, - form_content: '', - inputs: [], - user_actions: [], - timeout: 36, - timeout_unit: 'hour', - }, - metaData: { isSingleton: false }, - }, - } - - const { result } = renderWorkflowHook(() => useNodesInteractions(), { - historyStore: { nodes: currentNodes, edges: [] }, - }) - - act(() => { - result.current.handleNodeAdd({ nodeType: BlockEnum.HumanInputV2 }, { prevNodeId: 'node-1' }) - }) - - const node = rfState.nodes.find((node) => node.data.version === '2') - expect(node?.data).toMatchObject({ - type: BlockEnum.HumanInput, - version: '2', - recipients_spec: [], - timeout: 36, - timeout_unit: 'hour', - }) - expect(node?.data).not.toHaveProperty('delivery_methods') - expect(node?.data._connectedSourceHandleIds).toEqual([]) - expect(node?.parentId).toBe(parentId) - if (containerType) { - expect(node?.data).toMatchObject({ - isInIteration: containerType === BlockEnum.Iteration, - isInLoop: containerType === BlockEnum.Loop, - [containerType === BlockEnum.Iteration ? 'iteration_id' : 'loop_id']: parentId, - }) - } +const v2Node = () => + createNode({ + id: 'copied-v2', + data: { ...HumanInputV2Default.defaultValue, type: BlockEnum.HumanInput, title: 'Approval' }, + }) +const Catalog = ({ children }: { children: ReactNode }) => + createElement(Fragment, null, createElement(CanvasNodesCatalogProbe), children) +function setup(nodes: Node[]) { + const { queryClient } = createConsoleQueryWrapper() + seedModelProviderQueries(queryClient) + seedAppDslVersion(queryClient, '1.0.0') + return renderWorkflowCanvasHook( + () => ({ + ...useNodesInteractions(), + canvas: useStoreApi(), + }), + { + nodes, + edges: [], + queryClient, + historyStore: { nodes, edges: [] }, + wrapper: Catalog, + hooksStoreProps: { doSyncWorkflowDraft: async () => null }, }, ) +} - it('blocks quick-add Human Input while a legacy node remains in the draft', () => { - currentNodes = [ +beforeEach(async () => { + await navigator.clipboard.writeText('') + vi.useFakeTimers() +}) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +it.each([undefined, BlockEnum.Iteration, BlockEnum.Loop])( + 'creates Human Input v2 with persisted type in container %s', + (containerType) => { + const parentId = containerType ? 'container-node' : undefined + const nodes = [ createNode({ - id: 'legacy-human-input', - data: { - type: BlockEnum.HumanInput, - title: 'Legacy Human Input', - desc: '', - delivery_methods: [], - }, + id: 'node-1', + parentId, + width: 100, + data: { type: BlockEnum.Code, title: 'Code', desc: '' }, }), - createNode({ id: 'node-1', data: { type: BlockEnum.Code, title: 'Code', desc: '' } }), ] - rfState.nodes = currentNodes as unknown as typeof rfState.nodes - runtimeNodesMetaDataMap.value = { - [BlockEnum.HumanInputV2]: { - defaultValue: { - type: BlockEnum.HumanInput, - version: '2', - title: 'Human Input', - desc: '', - }, - metaData: { isSingleton: false }, - }, - } - const { result } = renderWorkflowHook(() => useNodesInteractions(), { - historyStore: { nodes: currentNodes, edges: [] }, - }) + if (containerType) + nodes.unshift( + createNode({ + id: parentId, + width: 500, + height: 400, + data: { type: containerType, title: 'Container', desc: '' }, + }), + ) + const { result } = setup(nodes) + act(() => + result.current.handleNodeAdd({ nodeType: BlockEnum.HumanInputV2 }, { prevNodeId: 'node-1' }), + ) - act(() => { - result.current.handleNodeAdd({ nodeType: BlockEnum.HumanInputV2 }, { prevNodeId: 'node-1' }) - }) + const node = result.current.canvas + .getState() + .getNodes() + .find((node) => node.data.version === '2') + expect(node?.data).toMatchObject({ + type: BlockEnum.HumanInput, + version: '2', + recipients_spec: [], + timeout: 36, + timeout_unit: 'hour', + }) + expect(node?.data).not.toHaveProperty('delivery_methods') + expect(node?.data._connectedSourceHandleIds).toEqual([]) + expect(node?.parentId).toBe(parentId) + if (containerType) + expect(node?.data).toMatchObject({ + isInIteration: containerType === BlockEnum.Iteration, + isInLoop: containerType === BlockEnum.Loop, + [containerType === BlockEnum.Iteration ? 'iteration_id' : 'loop_id']: parentId, + }) + }, +) - expect(rfState.setNodes).not.toHaveBeenCalled() - expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() - expect(mockSaveStateToHistory).not.toHaveBeenCalled() - }) - - it('blocks pasted Human Input while a legacy node remains in the draft', async () => { - const legacyNode = createNode({ - id: 'legacy-human-input', - data: { - type: BlockEnum.HumanInput, - title: 'Legacy Human Input', - desc: '', - delivery_methods: [], - }, - }) - const copiedV2Node = createNode({ - id: 'copied-v2', - data: { - type: BlockEnum.HumanInput, - version: '2', - title: 'Human Input', - desc: '', - }, - }) - currentNodes = [legacyNode] - rfState.nodes = currentNodes as unknown as typeof rfState.nodes - runtimeNodesMetaDataMap.value = { - [BlockEnum.HumanInput]: { - defaultValue: { type: BlockEnum.HumanInput, delivery_methods: [] }, - metaData: { isSingleton: false }, - }, - [BlockEnum.HumanInputV2]: { - defaultValue: { type: BlockEnum.HumanInput, version: '2' }, - metaData: { isSingleton: false }, - }, - } - const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { - historyStore: { nodes: currentNodes, edges: [] }, - }) - store.setState({ - clipboardElements: [copiedV2Node] as never, - clipboardEdges: [] as never, - mousePosition: { pageX: 60, pageY: 80 } as never, - }) - +it.each(['add', 'paste', 'duplicate'] as const)( + 'preserves the canvas when legacy approval blocks %s', + async (command) => { + const nodes = [legacyNode(), createNode({ id: 'node-1' })] + const { result } = setup(nodes) + const before = result.current.canvas.getState().getNodes() + if (command === 'paste') await writeWorkflowClipboard({ nodes: [v2Node()], edges: [] }, '1.0.0') await act(async () => { - await result.current.handleNodesPaste() - }) + if (command === 'add') + result.current.handleNodeAdd({ nodeType: BlockEnum.HumanInputV2 }, { prevNodeId: 'node-1' }) + else if (command === 'paste') await result.current.handleNodesPaste() + else result.current.handleNodesDuplicate('legacy-human-input') + await vi.advanceTimersByTimeAsync(600) + }) + expect(result.current.canvas.getState().getNodes()).toEqual(before) + expect(result.current.canvas.getState().edges).toEqual([]) + }, +) - expect(rfState.setNodes).not.toHaveBeenCalled() - expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() - expect(mockSaveStateToHistory).not.toHaveBeenCalled() +it('pastes Human Input v2 without losing wire data and remaps copied dependencies', async () => { + const copiedSourceNode = createNode({ + id: 'source-copy', + data: { + type: BlockEnum.Code, + title: 'Email source', + desc: '', + variables: [], + code_language: 'python3', + code: '', + outputs: { email: { type: 'string', children: null } }, + }, }) - - it('blocks duplicated Human Input while a legacy node remains in the draft', async () => { - const legacyNode = createNode({ - id: 'legacy-human-input', - data: { - type: BlockEnum.HumanInput, - title: 'Legacy Human Input', - desc: '', - delivery_methods: [], + const copiedNode = createNode({ + id: 'human-input-v2-copy', + data: { + type: BlockEnum.HumanInput, + version: '2', + title: 'Review request', + desc: '', + recipients_spec: [ + { type: 'initiator' }, + { type: 'dynamic_email', selector: ['source-copy', 'email'] }, + { type: 'onetime_email', email: 'owner@example.com' }, + ], + message_template: { + subject: 'Review {{#source-copy.email#}}', + body: 'Body {{#source-copy.email#}}', }, - }) - currentNodes = [legacyNode] - rfState.nodes = currentNodes as unknown as typeof rfState.nodes - runtimeNodesMetaDataMap.value = { - [BlockEnum.HumanInput]: { - defaultValue: { type: BlockEnum.HumanInput, delivery_methods: [] }, - metaData: { isSingleton: false }, - }, - } - const readTextSpy = vi.spyOn(navigator.clipboard, 'readText').mockResolvedValue('') - const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() - const { result } = renderWorkflowHook(() => useNodesInteractions(), { - historyStore: { nodes: currentNodes, edges: [] }, - }) - - act(() => { - result.current.handleNodesDuplicate('legacy-human-input') - }) - await vi.waitFor(() => expect(readTextSpy).toHaveBeenCalled()) - - expect(writeTextSpy).toHaveBeenCalled() - expect(rfState.setNodes).not.toHaveBeenCalled() - expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() - expect(mockSaveStateToHistory).not.toHaveBeenCalled() - readTextSpy.mockRestore() - writeTextSpy.mockRestore() + debug_mode: { enabled: true, channels: ['email', 'slack'] }, + form_content: '{{#source-copy.email#}}', + inputs: [], + user_actions: [], + timeout: 36, + timeout_unit: 'hour', + }, }) - - it('pastes Human Input v2 without losing wire data and remaps copied dependencies', async () => { - currentNodes = [ - createNode({ id: 'existing-node', data: { type: BlockEnum.Code, title: 'Code', desc: '' } }), - ] - rfState.nodes = currentNodes as unknown as typeof rfState.nodes - rfState.edges = [] - runtimeNodesMetaDataMap.value = { - [BlockEnum.Code]: { - defaultValue: { - type: BlockEnum.Code, - title: 'Code', - desc: '', - variables: [], - code_language: 'python3', - code: '', - outputs: {}, - }, - metaData: { isSingleton: false }, - }, - [BlockEnum.HumanInputV2]: { - defaultValue: { - type: BlockEnum.HumanInput, - version: '2', - recipients_spec: [], - message_template: { subject: '', body: '' }, - debug_mode: { enabled: false, channels: [] }, - form_content: '', - inputs: [], - user_actions: [], - timeout: 36, - timeout_unit: 'hour', - }, - metaData: { isSingleton: false }, - }, - } - const copiedSourceNode = createNode({ - id: 'source-copy', - data: { - type: BlockEnum.Code, - title: 'Email source', - desc: '', - variables: [], - code_language: 'python3', - code: '', - outputs: { email: { type: 'string', children: null } }, - }, - }) - const copiedNode = createNode({ - id: 'human-input-v2-copy', - data: { - type: BlockEnum.HumanInput, - version: '2', - title: 'Review request', - desc: '', - recipients_spec: [ - { type: 'initiator' }, - { type: 'dynamic_email', selector: ['source-copy', 'email'] }, - { type: 'onetime_email', email: 'owner@example.com' }, - ], - message_template: { - subject: 'Review {{#source-copy.email#}}', - body: 'Body {{#source-copy.email#}}', - }, - debug_mode: { enabled: true, channels: ['email', 'slack'] }, - form_content: '{{#source-copy.email#}}', - inputs: [], - user_actions: [], - timeout: 36, - timeout_unit: 'hour', - }, - }) - const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { - historyStore: { nodes: currentNodes, edges: [] }, - }) - store.setState({ - clipboardElements: [copiedSourceNode, copiedNode] as never, - clipboardEdges: [] as never, - mousePosition: { pageX: 60, pageY: 80 } as never, - }) - await act(async () => { - await result.current.handleNodesPaste() - }) + const { result } = setup([createNode({ id: 'existing-node' })]) + await writeWorkflowClipboard({ nodes: [copiedSourceNode, copiedNode], edges: [] }, '1.0.0') + await act(async () => result.current.handleNodesPaste()) - const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] - const pasted = pastedNodes.find((node) => (node.data as { version?: unknown }).version === '2') - const pastedData = pasted?.data as HumanInputV2NodeType - const copiedData = copiedNode.data as HumanInputV2NodeType - const pastedSource = pastedNodes.find( - (node) => node.data.type === BlockEnum.Code && node.id !== 'existing-node', - ) - expect(pastedData.type).toBe(BlockEnum.HumanInput) - expect(pastedData.recipients_spec).toEqual([ - { type: 'initiator' }, - { type: 'dynamic_email', selector: [pastedSource?.id, 'email'] }, - { type: 'onetime_email', email: 'owner@example.com' }, - ]) - expect(pastedData.message_template).toEqual({ - subject: `Review {{#${pastedSource?.id}.email#}}`, - body: `Body {{#${pastedSource?.id}.email#}}`, - }) - expect(pastedData.form_content).toBe(`{{#${pastedSource?.id}.email#}}`) - expect(pastedData.debug_mode).toEqual(copiedData.debug_mode) + const pastedNodes = result.current.canvas.getState().getNodes() + expect(pastedNodes).toHaveLength(3) + const pasted = pastedNodes.find((node) => node.data.version === '2') + const pastedData = pasted?.data as HumanInputV2NodeType + const copiedData = copiedNode.data as HumanInputV2NodeType + const pastedSource = pastedNodes.find( + (node) => node.data.type === BlockEnum.Code && node.id !== 'existing-node', + ) + expect(pastedSource?.id).toBeDefined() + expect(pastedSource?.id).not.toBe('source-copy') + expect(pastedData.type).toBe(BlockEnum.HumanInput) + expect(pastedData.recipients_spec).toEqual([ + { type: 'initiator' }, + { type: 'dynamic_email', selector: [pastedSource?.id, 'email'] }, + { type: 'onetime_email', email: 'owner@example.com' }, + ]) + expect(pastedData.message_template).toEqual({ + subject: `Review {{#${pastedSource?.id}.email#}}`, + body: `Body {{#${pastedSource?.id}.email#}}`, }) - - // Paste title handling should preserve original names until the destination canvas conflicts. - - // A copied container can still be selected on the source canvas during same-canvas paste. - - // Nested container paste behavior should stay aligned with available block filtering. + expect(pastedData.form_content).toBe(`{{#${pastedSource?.id}.email#}}`) + expect(pastedData.debug_mode).toEqual(copiedData.debug_mode) }) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx index 2a6d6c8029e..c1bbb779228 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -29,13 +29,6 @@ vi.mock('@/service/console', async (importOriginal) => { } }) -// The editor runtime is a browser boundary; keep form state and value serialization real. -vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ onChange, value }: { onChange: (value: string) => void; value: string }) => ( -