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