import type { Edge, Node } from '../types' import { ContextMenu } from '@langgenius/dify-ui/context-menu' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { useEffect } from 'react' import { useNodes } from 'reactflow' import { PipelineInputVarType } from '@/models/pipeline' import { SelectionContextmenu } from '../selection-contextmenu' import { useWorkflowStore } from '../store' import { BlockEnum } from '../types' import { useWorkflowHistoryStore } from '../workflow-history-store' import { createEdge, createNode } from './fixtures' import { renderWorkflowFlowComponent } from './workflow-test-env' let latestNodes: Node[] = [] let latestHistoryEvent: string | undefined const mockGetNodesReadOnly = vi.fn() const mockHandleNodesCopy = vi.fn() const mockHandleNodesDuplicate = vi.fn() const mockHandleNodesDelete = vi.fn() const mockHandleCreateSnippet = vi.fn() const mockCreateSnippetDialogRender = vi.fn() vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => { const React = await vi.importActual('react') return { useCreateSnippet: () => { const [isOpen, setIsOpen] = React.useState(false) return { createSnippetMutation: { isPending: false }, handleCloseCreateSnippetDialog: () => setIsOpen(false), handleCreateSnippet: mockHandleCreateSnippet, handleOpenCreateSnippetDialog: () => setIsOpen(true), isCreateSnippetDialogOpen: isOpen, isCreatingSnippet: false, } }, } }) vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ default: (props: { isOpen: boolean selectedGraph?: { nodes: Node[], edges: Edge[], viewport: { x: number, y: number, zoom: number } } inputFields?: Array<{ variable: string }> }) => { mockCreateSnippetDialogRender(props) return props.isOpen ?
: null }, })) vi.mock('../hooks', async () => { const actual = await vi.importActual('../hooks') return { ...actual, useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly, }), useNodesInteractions: () => ({ handleNodesCopy: mockHandleNodesCopy, handleNodesDuplicate: mockHandleNodesDuplicate, handleNodesDelete: mockHandleNodesDelete, }), } }) const RuntimeProbe = () => { latestNodes = useNodes() as Node[] const { store } = useWorkflowHistoryStore() useEffect(() => { latestHistoryEvent = store.getState().workflowHistoryEvent return store.subscribe((state) => { latestHistoryEvent = state.workflowHistoryEvent }) }, [store]) return null } const hooksStoreProps = { doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), } const SelectionMenuHarness = () => { const workflowStore = useWorkflowStore() return ( workflowStore.getState().setContextMenuTarget(undefined)} /> ) } const renderSelectionMenu = (options?: { nodes?: Node[] edges?: Edge[] initialStoreState?: Record }) => { latestNodes = [] latestHistoryEvent = undefined const nodes = options?.nodes ?? [] const edges = options?.edges ?? [] return renderWorkflowFlowComponent(
, { nodes, edges, hooksStoreProps, historyStore: { nodes, edges }, initialStoreState: options?.initialStoreState, reactFlowProps: { fitView: false }, }, ) } describe('SelectionContextmenu', () => { beforeEach(() => { vi.clearAllMocks() latestNodes = [] latestHistoryEvent = undefined mockGetNodesReadOnly.mockReset() mockGetNodesReadOnly.mockReturnValue(false) mockHandleNodesCopy.mockReset() mockHandleNodesDuplicate.mockReset() mockHandleNodesDelete.mockReset() mockHandleCreateSnippet.mockReset() mockCreateSnippetDialogRender.mockReset() }) it('should not render when selection context menu target is absent', () => { renderSelectionMenu() expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument() }) it('should render menu items when selection context menu target is present', async () => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40 }), createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), ] const { store } = renderSelectionMenu({ nodes }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) await waitFor(() => { expect(screen.getByTestId('selection-contextmenu-item-left')).toBeInTheDocument() }) }) it('should render and execute copy/duplicate/delete operations', async () => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40 }), createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), ] const { store } = renderSelectionMenu({ nodes }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) await waitFor(() => { expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() }) fireEvent.click(screen.getByRole('menuitem', { name: /common.copy/ })) expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1) expect(store.getState().contextMenuTarget).toBeUndefined() act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByRole('menuitem', { name: /common.duplicate/ })) expect(mockHandleNodesDuplicate).toHaveBeenCalledTimes(1) expect(store.getState().contextMenuTarget).toBeUndefined() act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByRole('menuitem', { name: /operation.delete/ })) expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1) expect(store.getState().contextMenuTarget).toBeUndefined() }) it('should open create snippet dialog with selected graph from the top menu item', async () => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40 }), createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), createNode({ id: 'n3', selected: false, position: { x: 260, y: 0 }, width: 80, height: 40 }), ] const edges = [ createEdge({ source: 'n1', target: 'n2' }), createEdge({ source: 'n2', target: 'n3' }), ] const { store } = renderSelectionMenu({ nodes, edges }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })) expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument() expect(store.getState().contextMenuTarget).toBeUndefined() const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0] expect(dialogProps.selectedGraph.nodes.map((node: Node) => node.id)).toEqual(['n1', 'n2']) expect(dialogProps.selectedGraph.nodes.every((node: Node) => node.selected === false)).toBe(true) expect(dialogProps.selectedGraph.edges).toHaveLength(1) expect(dialogProps.selectedGraph.viewport).toEqual({ x: 490, y: 380, zoom: 1 }) expect(dialogProps.selectedGraph.edges[0]).toEqual(expect.objectContaining({ source: 'n1', target: 'n2', selected: false, })) }) it('should add input fields for variable references outside of the selected graph', async () => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40, data: { prompt_template: 'Use {{#source-node.topic#}} and {{#n2.answer#}}', query_variable_selector: ['source-node', 'topic'], env_reference: '{{#env.API_KEY#}}', }, }), createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40, }), ] const { store } = renderSelectionMenu({ nodes }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })) const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0] expect(dialogProps.inputFields).toEqual([ { label: 'topic', variable: 'topic', type: PipelineInputVarType.textInput, required: true, }, { label: 'API_KEY', variable: 'API_KEY', type: PipelineInputVarType.textInput, required: true, }, ]) expect(dialogProps.selectedGraph.nodes[0].data.prompt_template).toBe('Use {{#start.topic#}} and {{#n2.answer#}}') expect(dialogProps.selectedGraph.nodes[0].data.query_variable_selector).toEqual(['start', 'topic']) expect(dialogProps.selectedGraph.nodes[0].data.env_reference).toBe('{{#start.API_KEY#}}') }) it.each([ BlockEnum.Answer, BlockEnum.End, BlockEnum.Start, ])('should hide create snippet when selection contains %s node', async (nodeType) => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40, data: { type: nodeType } }), createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), ] const { store } = renderSelectionMenu({ nodes }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) await waitFor(() => { expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() }) expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument() }) it('should stay hidden when only one node is selected', async () => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40 }), ] const { store } = renderSelectionMenu({ nodes }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) await waitFor(() => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) }) it('should align selected nodes to the left and save history', async () => { vi.useFakeTimers() const nodes = [ createNode({ id: 'n1', selected: true, position: { x: 20, y: 40 }, width: 40, height: 20 }), createNode({ id: 'n2', selected: true, position: { x: 140, y: 90 }, width: 60, height: 30 }), ] const { store } = renderSelectionMenu({ nodes, edges: [createEdge({ source: 'n1', target: 'n2' })], initialStoreState: { helpLineHorizontal: { y: 10 } as never, helpLineVertical: { x: 10 } as never, }, }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(20) expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(20) expect(store.getState().contextMenuTarget).toBeUndefined() expect(store.getState().helpLineHorizontal).toBeUndefined() expect(store.getState().helpLineVertical).toBeUndefined() act(() => { store.getState().flushPendingSync() vi.advanceTimersByTime(600) }) expect(hooksStoreProps.doSyncWorkflowDraft).toHaveBeenCalled() expect(latestHistoryEvent).toBe('NodeDragStop') vi.useRealTimers() }) it('should distribute selected nodes horizontally', async () => { const nodes = [ createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }), createNode({ id: 'n2', selected: true, position: { x: 100, y: 20 }, width: 20, height: 20 }), createNode({ id: 'n3', selected: true, position: { x: 300, y: 30 }, width: 20, height: 20 }), ] const { store } = renderSelectionMenu({ nodes, }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal')) expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(150) }) it('should ignore child nodes when the selected container is aligned', async () => { const nodes = [ createNode({ id: 'container', selected: true, position: { x: 200, y: 0 }, width: 100, height: 80, data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, }), createNode({ id: 'child', selected: true, position: { x: 210, y: 10 }, width: 30, height: 20, }), createNode({ id: 'other', selected: true, position: { x: 40, y: 60 }, width: 40, height: 20, }), ] const { store } = renderSelectionMenu({ nodes, }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(40) expect(latestNodes.find(node => node.id === 'other')?.position.x).toBe(40) expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(210) }) it('should cancel when align bounds cannot be resolved', () => { const nodes = [ createNode({ id: 'n1', selected: true }), createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 } }), ] const { store } = renderSelectionMenu({ nodes }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) expect(store.getState().contextMenuTarget).toBeUndefined() }) it('should cancel without aligning when nodes are read only', () => { mockGetNodesReadOnly.mockReturnValue(true) const nodes = [ createNode({ id: 'n1', selected: true, width: 40, height: 20 }), createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }), ] const { store } = renderSelectionMenu({ nodes }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) expect(store.getState().contextMenuTarget).toBeUndefined() expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0) expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80) }) it('should cancel when alignable nodes shrink to one item', () => { const nodes = [ createNode({ id: 'container', selected: true, width: 40, height: 20, data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, }), createNode({ id: 'child', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }), ] const { store } = renderSelectionMenu({ nodes }) act(() => { store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) expect(store.getState().contextMenuTarget).toBeUndefined() expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(0) expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(80) }) })