mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 14:51:39 +08:00
test(web): exercise HITL graph and configuration behavior through real owners (#45108)
This commit is contained in:
1 parent
1ec7ec230d
commit
d4f41acfc2
4 files changed
+267
-715
No files matched your search
+12
-65
@@ -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()
|
||||
})
|
||||
+81
-189
@@ -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([])
|
||||
})
|
||||
+172
-452
@@ -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)
|
||||
})
|
||||
+2
-9
@@ -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}' },
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user