test(web): exercise HITL graph and configuration behavior through real owners (#45108)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-30 03:27:53 +00:00
1 parent 1ec7ec230d
commit d4f41acfc2
4 files changed
+267 -715

No files matched your search

@@ -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
}) => (
<button type="button" onClick={() => onModifyLoadBalancing(model)}>
{model.model}
</button>
),
}))
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(<ModelList provider={provider} models={[model]} onCollapse={vi.fn()} />)
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()
})
@@ -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<typeof import('@/config')>()),
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<SnippetWorkflowResponse, 'graph'>
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<PublishedWorkflow>(queryOptions.queryKey, workflow)
function setup(nodes: Node[], insertedNode: Node) {
const queryClient = createTestQueryClient()
queryClient.setQueryData<Pick<SnippetWorkflowResponse, 'graph'>>(
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([])
})
@@ -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<string, unknown>,
}))
const runtimeState = vi.hoisted(() => ({
isChatMode: false,
nodesReadOnly: false,
workflowReadOnly: false,
}))
vi.mock('@/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/config')>()),
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<typeof import('../use-workflow-history')>()),
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)
})
@@ -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 }) => (
<textarea aria-label="JSON" value={value} onChange={(event) => onChange(event.target.value)} />
),
}))
type Props = ComponentProps<typeof FormInputItem>
const createSchema = (overrides: Partial<Props['schema']> = {}): Props['schema'] => ({
label: { en_US: 'Field', zh_Hans: '字段' },
@@ -243,10 +236,10 @@ describe('FormInputItem selections', () => {
schema: createSchema({ type: FormTypeEnum.object }),
value: { field: { type: VarKindType.constant, value: '{"enabled":false}' } },
})
await user.clear(screen.getByRole('textbox', { name: 'JSON' }))
await user.clear(screen.getByTestId('monaco-editor'))
await user.paste('{"enabled":true}')
expect(screen.getByRole('textbox', { name: 'JSON' })).toHaveValue('{"enabled":true}')
expect(screen.getByTestId('monaco-editor')).toHaveValue('{"enabled":true}')
expect(onChange).toHaveBeenLastCalledWith({
field: { type: VarKindType.constant, value: '{"enabled":true}' },
})