import type { Edge, Node } from '../types' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useEffect } from 'react' import { useEdges, useNodes, useStoreApi } from 'reactflow' import { createEdge, createNode } from '../__tests__/fixtures' import { renderWorkflowFlowComponent } from '../__tests__/workflow-test-env' import EdgeContextmenu from '../edge-contextmenu' import { useEdgesInteractions } from '../hooks/use-edges-interactions' const mockSaveStateToHistory = vi.fn() vi.mock('../hooks/use-workflow-history', () => ({ useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }), WorkflowHistoryEvent: { EdgeDelete: 'EdgeDelete', EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch', EdgeSourceHandleChange: 'EdgeSourceHandleChange', }, })) vi.mock('../hooks/use-workflow', () => ({ useNodesReadOnly: () => ({ getNodesReadOnly: () => false, }), })) vi.mock('../utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})), } }) vi.mock('../hooks', async () => { const { useEdgesInteractions } = await import('../hooks/use-edges-interactions') const { usePanelInteractions } = await import('../hooks/use-panel-interactions') return { useEdgesInteractions, usePanelInteractions, } }) type EdgeRuntimeState = { _hovering?: boolean _isBundled?: boolean } type NodeRuntimeState = { selected?: boolean _isBundled?: boolean } const getEdgeRuntimeState = (edge?: Edge): EdgeRuntimeState => (edge?.data ?? {}) as EdgeRuntimeState const getNodeRuntimeState = (node?: Node): NodeRuntimeState => (node?.data ?? {}) as NodeRuntimeState function createFlowNodes() { return [ createNode({ id: 'n1' }), createNode({ id: 'n2', position: { x: 100, y: 0 } }), ] } function createFlowEdges() { return [ createEdge({ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false }, selected: true, }), createEdge({ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false }, }), ] } let latestNodes: Node[] = [] let latestEdges: Edge[] = [] const RuntimeProbe = () => { latestNodes = useNodes() as Node[] latestEdges = useEdges() as Edge[] return null } const hooksStoreProps = { doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), } const EdgeMenuHarness = () => { const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions() const edges = useEdges() as Edge[] const reactFlowStore = useStoreApi() useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Delete' && e.key !== 'Backspace') return e.preventDefault() handleEdgeDelete() } document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('keydown', handleKeyDown) } }, [handleEdgeDelete]) return (
) } function renderEdgeMenu(options?: { nodes?: Node[] edges?: Edge[] initialStoreState?: Record }) { const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {} return renderWorkflowFlowComponent(, { nodes, edges, initialStoreState, hooksStoreProps, reactFlowProps: { fitView: false }, }) } describe('EdgeContextmenu', () => { beforeEach(() => { vi.clearAllMocks() latestNodes = [] latestEdges = [] }) it('should not render when edgeMenu is absent', () => { renderWorkflowFlowComponent(, { nodes: createFlowNodes(), edges: createFlowEdges(), hooksStoreProps, reactFlowProps: { fitView: false }, }) expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) it('should delete the menu edge and close the menu when another edge is selected', async () => { const user = userEvent.setup() const { store } = renderEdgeMenu({ edges: [ createEdge({ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', selected: true, data: { _hovering: false }, }), createEdge({ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', selected: false, data: { _hovering: false }, }), ], initialStoreState: { edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2', }, }, }) const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i }) expect(screen.getByText(/^del$/i)).toBeInTheDocument() await user.click(deleteAction) await waitFor(() => { expect(latestEdges).toHaveLength(1) expect(latestEdges[0].id).toBe('e1') expect(latestEdges[0].selected).toBe(true) expect(store.getState().edgeMenu).toBeUndefined() expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') }) it('should not render the menu when the referenced edge no longer exists', () => { renderWorkflowFlowComponent(, { nodes: createFlowNodes(), edges: createFlowEdges(), initialStoreState: { edgeMenu: { clientX: 320, clientY: 180, edgeId: 'missing-edge', }, }, hooksStoreProps, reactFlowProps: { fitView: false }, }) expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) it('should open the edge menu at the right-click position', async () => { const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') renderEdgeMenu() fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { clientX: 320, clientY: 180, }) expect(await screen.findByRole('menu')).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument() expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ x: 320, y: 180, width: 0, height: 0, })) }) it('should delete the right-clicked edge and close the menu when delete is clicked', async () => { const user = userEvent.setup() renderEdgeMenu() fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { clientX: 320, clientY: 180, }) await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i })) await waitFor(() => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() expect(latestEdges.map(edge => edge.id)).toEqual(['e1']) }) expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') }) it.each([ ['Delete', 'Delete'], ['Backspace', 'Backspace'], ])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => { renderEdgeMenu({ nodes: [ createNode({ id: 'n1', selected: true, data: { selected: true, _isBundled: true }, }), createNode({ id: 'n2', position: { x: 100, y: 0 }, }), ], }) fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { clientX: 240, clientY: 120, }) expect(await screen.findByRole('menu')).toBeInTheDocument() fireEvent.keyDown(document.body, { key }) await waitFor(() => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() expect(latestEdges.map(edge => edge.id)).toEqual(['e1']) expect(latestNodes.map(node => node.id)).toEqual(['n1', 'n2']) expect(latestNodes.every(node => !node.selected && !getNodeRuntimeState(node).selected)).toBe(true) }) }) it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => { renderEdgeMenu({ nodes: [ createNode({ id: 'n1', selected: true, data: { selected: true, _isBundled: true }, }), createNode({ id: 'n2', position: { x: 100, y: 0 }, selected: true, data: { selected: true, _isBundled: true }, }), ], }) fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), { clientX: 200, clientY: 100, }) expect(await screen.findByRole('menu')).toBeInTheDocument() fireEvent.keyDown(document.body, { key: 'Delete' }) await waitFor(() => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() expect(latestEdges.map(edge => edge.id)).toEqual(['e2']) expect(latestNodes).toHaveLength(2) expect(latestNodes.every(node => !node.selected && !getNodeRuntimeState(node).selected && !getNodeRuntimeState(node)._isBundled, )).toBe(true) }) }) it('should retarget the menu and selected edge when right-clicking a different edge', async () => { const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') renderEdgeMenu() const edgeOneButton = screen.getByLabelText('Right-click edge e1') const edgeTwoButton = screen.getByLabelText('Right-click edge e2') fireEvent.contextMenu(edgeOneButton, { clientX: 80, clientY: 60, }) expect(await screen.findByRole('menu')).toBeInTheDocument() fireEvent.contextMenu(edgeTwoButton, { clientX: 360, clientY: 240, }) await waitFor(() => { expect(screen.getAllByRole('menu')).toHaveLength(1) expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ x: 360, y: 240, })) expect(latestEdges.find(edge => edge.id === 'e1')?.selected).toBe(false) expect(latestEdges.find(edge => edge.id === 'e2')?.selected).toBe(true) expect(latestEdges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true) }) }) it('should hide the menu when the target edge disappears after opening it', async () => { const { container } = renderEdgeMenu() fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), { clientX: 160, clientY: 100, }) expect(await screen.findByRole('menu')).toBeInTheDocument() fireEvent.click(container.querySelector('button[aria-label="Remove edge e1"]') as HTMLButtonElement) await waitFor(() => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) }) })