diff --git a/web/app/components/workflow/hooks/__tests__/use-locate-node.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-locate-node.spec.tsx index a0f62b117d6..7d749c682e2 100644 --- a/web/app/components/workflow/hooks/__tests__/use-locate-node.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-locate-node.spec.tsx @@ -1,168 +1,144 @@ -import { renderHook } from '@testing-library/react' +import type { Node } from '../../types' +import { act } from '@testing-library/react' +import { useReactFlow } from 'reactflow' +import { seedModelProviderQueries } from '@/test/console/model-provider' +import { createConsoleQueryWrapper } from '@/test/console/query-data' import { createNode } from '../../__tests__/fixtures' +import { renderWorkflowCanvasHook } from '../../__tests__/workflow-test-env' import { useLocateNode } from '../use-locate-node' +import { useNodesInteractions } from '../use-nodes-interactions' -const mockHandleNodeSelect = vi.hoisted(() => vi.fn()) -const mockScrollToWorkflowNode = vi.hoisted(() => vi.fn()) -const mockSearchParams = vi.hoisted(() => new URLSearchParams()) -const mockToastSuccess = vi.hoisted(() => vi.fn()) -const mockToastError = vi.hoisted(() => vi.fn()) - -vi.mock('@/next/navigation', () => ({ - useSearchParams: () => mockSearchParams, +const { searchParams, success, error } = vi.hoisted(() => ({ + searchParams: new URLSearchParams(), + success: vi.fn(), + error: vi.fn(), })) +vi.mock('@/next/navigation', () => ({ useSearchParams: () => searchParams })) +vi.mock('@/app/notifications', () => ({ toast: { success, error } })) -vi.mock('@/app/notifications', () => ({ - toast: Object.assign(vi.fn(), { - success: mockToastSuccess, - error: mockToastError, - warning: vi.fn(), - info: vi.fn(), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }), -})) - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: ( - selector: (source: Record) => string, - options?: Record, - ) => { - const key = selector( - new Proxy({}, { get: (_, property: string) => property }) as Record, - ) - if (options?.nodeId) return `${key}:${options.nodeId}` - if (options?.title) return `${key}:${options.title}` - return key +function renderLocate(nodes: Node[]) { + const { queryClient } = createConsoleQueryWrapper({ appDslVersion: '0.6.0' }) + seedModelProviderQueries(queryClient) + return renderWorkflowCanvasHook( + ({ loadedNodes }) => { + useLocateNode(loadedNodes) + return { flow: useReactFlow(), ...useNodesInteractions() } }, - }), -})) + { nodes, queryClient, initialProps: { loadedNodes: nodes } }, + ) +} -vi.mock('../use-nodes-interactions', () => ({ - useNodesInteractions: () => ({ - handleNodeSelect: mockHandleNodeSelect, - }), -})) +const selectedIds = (view: ReturnType) => + view.result.current.flow + .getNodes() + .filter((node) => node.selected) + .map((node) => node.id) -vi.mock('../../utils/node-navigation', () => ({ - scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId), -})) +// The document event is the public boundary consumed by the canvas scroll listener. +let scrollRequests: string[] +const onScroll = (event: Event) => { + scrollRequests.push((event as CustomEvent<{ nodeId: string }>).detail.nodeId) +} describe('useLocateNode', () => { beforeEach(() => { - vi.clearAllMocks() vi.useFakeTimers() - mockSearchParams.delete('node_id') + searchParams.delete('node_id') + scrollRequests = [] + document.addEventListener('workflow:scroll-to-node', onScroll) }) - afterEach(() => { + document.removeEventListener('workflow:scroll-to-node', onScroll) vi.clearAllTimers() vi.useRealTimers() }) - it('does nothing when node_id param is absent', () => { - const nodes = [createNode({ id: 'n1' })] - renderHook(() => useLocateNode(nodes)) + it('preserves selection when node_id is absent', () => { + const view = renderLocate([ + createNode({ id: 'existing', selected: true, data: { selected: true } }), + ]) + act(() => vi.advanceTimersByTime(500)) - expect(mockHandleNodeSelect).not.toHaveBeenCalled() - expect(mockToastSuccess).not.toHaveBeenCalled() - expect(mockToastError).not.toHaveBeenCalled() + expect(selectedIds(view)).toEqual(['existing']) + expect(success).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + expect(scrollRequests).toEqual([]) }) - it('does nothing when nodes are empty', () => { - mockSearchParams.set('node_id', 'n1') - renderHook(() => useLocateNode([])) + it('waits for nodes before reporting a missing target', () => { + searchParams.set('node_id', 'target') + const view = renderLocate([]) + act(() => vi.advanceTimersByTime(500)) - expect(mockHandleNodeSelect).not.toHaveBeenCalled() - expect(mockToastError).not.toHaveBeenCalled() + expect(view.result.current.flow.getNodes()).toEqual([]) + expect(success).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() }) - it('selects, scrolls to, and shows success toast when node is found', () => { - mockSearchParams.set('node_id', 'target-node') - const nodes = [createNode({ id: 'target-node', data: { title: 'My Node' } })] + it('selects the target instead of the previous node and requests scrolling after the delay', () => { + searchParams.set('node_id', 'target') + const view = renderLocate([ + createNode({ id: 'previous', selected: true, data: { selected: true } }), + createNode({ id: 'target', data: { title: 'My Node' } }), + ]) - renderHook(() => useLocateNode(nodes)) - - expect(mockHandleNodeSelect).toHaveBeenCalledWith('target-node') - expect(mockToastSuccess).toHaveBeenCalledWith('panel.locateNodeSuccess:My Node') - - // Scroll happens after a 200ms delay - expect(mockScrollToWorkflowNode).not.toHaveBeenCalled() - vi.advanceTimersByTime(200) - expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('target-node') + expect(selectedIds(view)).toEqual(['target']) + expect(view.result.current.flow.getNode('target')?.data.selected).toBe(true) + expect(view.result.current.flow.getNode('previous')?.data.selected).toBe(false) + expect(success).toHaveBeenCalledWith('workflow.panel.locateNodeSuccess:{"title":"My Node"}') + expect(scrollRequests).toEqual([]) + act(() => vi.advanceTimersByTime(200)) + expect(scrollRequests).toEqual(['target']) }) - it('shows error toast after debounce when node is not found', () => { - mockSearchParams.set('node_id', 'missing-node') - const nodes = [createNode({ id: 'other-node' })] + it('reports a missing target after nodes settle without changing selection', () => { + searchParams.set('node_id', 'missing') + const view = renderLocate([createNode({ id: 'other', selected: true })]) + expect(error).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(500)) - renderHook(() => useLocateNode(nodes)) - - // Not immediately reported - expect(mockToastError).not.toHaveBeenCalled() - - // After 500ms debounce, reports not found - vi.advanceTimersByTime(500) - expect(mockToastError).toHaveBeenCalledWith('panel.locateNodeNotFound:missing-node') + expect(error).toHaveBeenCalledWith('workflow.panel.locateNodeNotFound:{"nodeId":"missing"}') + expect(selectedIds(view)).toEqual(['other']) + expect(scrollRequests).toEqual([]) }) - it('does not report not-found prematurely when nodes are still loading', () => { - mockSearchParams.set('node_id', 'target-node') - const initialNodes = [createNode({ id: 'other-node' })] + it('selects a target that arrives before the not-found debounce expires', () => { + searchParams.set('node_id', 'target') + const initial = [createNode({ id: 'other' })] + const view = renderLocate(initial) + act(() => vi.advanceTimersByTime(300)) + const loadedNodes = [...initial, createNode({ id: 'target', data: { title: 'Found!' } })] + act(() => view.setCanvas({ nodes: loadedNodes })) + view.rerender({ loadedNodes }) - const { rerender } = renderHook(({ nodes }) => useLocateNode(nodes), { - initialProps: { nodes: initialNodes }, - }) - - // Before debounce fires, more nodes arrive including the target - vi.advanceTimersByTime(300) - rerender({ - nodes: [ - createNode({ id: 'other-node' }), - createNode({ id: 'target-node', data: { title: 'Found!' } }), - ], - }) - - // Should have located the node, not reported error - expect(mockToastError).not.toHaveBeenCalled() - expect(mockHandleNodeSelect).toHaveBeenCalledWith('target-node') - expect(mockToastSuccess).toHaveBeenCalledWith('panel.locateNodeSuccess:Found!') - vi.advanceTimersByTime(500) - expect(mockToastError).not.toHaveBeenCalled() - expect(mockScrollToWorkflowNode).toHaveBeenCalledExactlyOnceWith('target-node') + expect(selectedIds(view)).toEqual(['target']) + expect(success).toHaveBeenCalledWith('workflow.panel.locateNodeSuccess:{"title":"Found!"}') + act(() => vi.advanceTimersByTime(500)) + expect(error).not.toHaveBeenCalled() + expect(scrollRequests).toEqual(['target']) }) - it('locates only once even if nodes update afterwards', () => { - mockSearchParams.set('node_id', 'target-node') - const nodes1 = [createNode({ id: 'target-node' })] + it('does not steal selection back when nodes update after locating', () => { + searchParams.set('node_id', 'target') + const view = renderLocate([createNode({ id: 'target' }), createNode({ id: 'other' })]) + expect(selectedIds(view)).toEqual(['target']) + act(() => vi.advanceTimersByTime(200)) - const { rerender } = renderHook(({ nodes }) => useLocateNode(nodes), { - initialProps: { nodes: nodes1 }, - }) + act(() => view.result.current.handleNodeSelect('other')) + view.rerender({ loadedNodes: view.result.current.flow.getNodes() }) + act(() => vi.advanceTimersByTime(500)) - expect(mockHandleNodeSelect).toHaveBeenCalledTimes(1) - - // Simulate a nodes update after locate has already happened - rerender({ - nodes: [createNode({ id: 'target-node' }), createNode({ id: 'extra' })], - }) - - expect(mockHandleNodeSelect).toHaveBeenCalledTimes(1) - expect(mockToastSuccess).toHaveBeenCalledTimes(1) + expect(selectedIds(view)).toEqual(['other']) + expect(success).toHaveBeenCalledTimes(1) + expect(scrollRequests).toEqual(['target']) }) - it('clears scroll timeout on unmount', () => { - mockSearchParams.set('node_id', 'target-node') - const nodes = [createNode({ id: 'target-node' })] - - const { unmount } = renderHook(() => useLocateNode(nodes)) - - // Unmount before the 200ms scroll timer fires - unmount() - - vi.advanceTimersByTime(500) - expect(mockScrollToWorkflowNode).not.toHaveBeenCalled() + it('cancels pending scrolling when the editor unmounts', () => { + searchParams.set('node_id', 'target') + const view = renderLocate([createNode({ id: 'target' })]) + expect(selectedIds(view)).toEqual(['target']) + view.unmount() + act(() => vi.advanceTimersByTime(500)) + expect(scrollRequests).toEqual([]) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx index 544c39d9657..6bfec01ab33 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx @@ -1,149 +1,94 @@ -import { act } from '@testing-library/react' -import { useStoreApi } from 'reactflow' +import type { Shape as HooksStoreShape } from '../../hooks-store/store' +import { act, waitFor } from '@testing-library/react' +import { useReactFlow } from 'reactflow' import { createLoopNode, createNode } from '../../__tests__/fixtures' import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env' import { useWorkflowOrganize } from '../use-workflow-organize' -const mockSetViewport = vi.hoisted(() => vi.fn()) -const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn()) -const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) -const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn()) -const mockGetLayoutByELK = vi.hoisted(() => vi.fn()) - -const runtimeState = vi.hoisted(() => ({ - nodes: [] as ReturnType[], - edges: [] as { id: string; source: string; target: string }[], - nodesReadOnly: false, -})) - -vi.mock('reactflow', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useReactFlow: () => ({ ...actual.useReactFlow(), setViewport: mockSetViewport }), - } -}) - -vi.mock('../use-workflow', () => ({ - useNodesReadOnly: () => ({ - getNodesReadOnly: () => runtimeState.nodesReadOnly, - nodesReadOnly: runtimeState.nodesReadOnly, - }), -})) - -vi.mock('../use-nodes-sync-draft', () => ({ - useNodesSyncDraft: () => ({ - handleSyncWorkflowDraft: (...args: unknown[]) => mockHandleSyncWorkflowDraft(...args), - }), -})) - -vi.mock('../use-workflow-history', () => ({ - useWorkflowHistory: () => ({ - saveStateToHistory: (...args: unknown[]) => mockSaveStateToHistory(...args), - }), - WorkflowHistoryEvent: { - LayoutOrganize: 'LayoutOrganize', - }, +const { getLayoutForChildNodes, getLayoutByELK } = vi.hoisted(() => ({ + getLayoutForChildNodes: vi.fn(), + getLayoutByELK: vi.fn(), })) +// ELK owns layout calculation; applying its result, permissions and persistence stay real. vi.mock('../../utils/elk-layout', async (importOriginal) => ({ ...(await importOriginal()), - getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args), - getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args), + getLayoutForChildNodes, + getLayoutByELK, })) +const renderOrganize = (readOnly = false) => { + const nodes = [ + createLoopNode({ id: 'loop-node', width: 200, height: 160 }), + createNode({ + id: 'loop-child', + parentId: 'loop-node', + position: { x: 20, y: 20 }, + width: 100, + height: 60, + }), + createNode({ id: 'top-node', position: { x: 400, y: 0 }, width: 240, height: 100 }), + ] + const doSyncWorkflowDraft = vi + .fn() + .mockResolvedValue(undefined) + const view = renderWorkflowFlowHook(() => ({ ...useWorkflowOrganize(), flow: useReactFlow() }), { + nodes, + historyStore: { nodes, edges: [] }, + initialStoreState: { canvasReadOnly: readOnly }, + hooksStoreProps: { doSyncWorkflowDraft }, + reactFlowProps: { fitView: false, defaultViewport: { x: 40, y: 60, zoom: 1.2 } }, + }) + return { ...view, doSyncWorkflowDraft } +} + describe('useWorkflowOrganize', () => { beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - runtimeState.nodesReadOnly = false - runtimeState.nodes = [] - runtimeState.edges = [] - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('resizes containers, lays out nodes, and syncs draft when editable', async () => { - runtimeState.nodes = [ - createLoopNode({ - id: 'loop-node', - width: 200, - height: 160, - }), - createNode({ - id: 'loop-child', - parentId: 'loop-node', - position: { x: 20, y: 20 }, - width: 100, - height: 60, - }), - createNode({ - id: 'top-node', - position: { x: 400, y: 0 }, - }), - ] - runtimeState.edges = [] - mockGetLayoutForChildNodes.mockResolvedValue({ + getLayoutForChildNodes.mockResolvedValue({ bounds: { minX: 0, minY: 0, maxX: 320, maxY: 220 }, nodes: new Map([['loop-child', { x: 40, y: 60, width: 100, height: 60 }]]), }) - mockGetLayoutByELK.mockResolvedValue({ + getLayoutByELK.mockResolvedValue({ nodes: new Map([ ['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }], ['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }], ]), }) - - const { result } = renderWorkflowFlowHook( - () => ({ ...useWorkflowOrganize(), graph: useStoreApi() }), - { nodes: runtimeState.nodes }, - ) - - await act(async () => { - await result.current.handleLayout() - }) - act(() => { - vi.runAllTimers() - }) - - const nextNodes = result.current.graph.getState().getNodes() - expect(nextNodes.find((node: { id: string }) => node.id === 'loop-node')).toEqual( - expect.objectContaining({ - width: 440, - height: 340, - position: { x: 10, y: 20 }, - }), - ) - expect(nextNodes.find((node: { id: string }) => node.id === 'loop-child')).toEqual( - expect.objectContaining({ - position: { x: 100, y: 120 }, - }), - ) - expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 0.7 }) - expect(mockSaveStateToHistory).toHaveBeenCalledWith('LayoutOrganize') - expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled() }) - it('skips layout when nodes are read-only', async () => { - runtimeState.nodesReadOnly = true - runtimeState.nodes = [createNode({ id: 'n1' })] + it('applies layout and container bounds to the canvas and records the resulting graph', async () => { + const { result, store, doSyncWorkflowDraft } = renderOrganize() + await act(async () => result.current.handleLayout()) - const { result } = renderWorkflowFlowHook( - () => ({ ...useWorkflowOrganize(), graph: useStoreApi() }), - { nodes: runtimeState.nodes }, - ) - - await act(async () => { - await result.current.handleLayout() + expect(result.current.flow.getNode('loop-node')).toMatchObject({ + width: 440, + height: 340, + position: { x: 10, y: 20 }, + data: { width: 440, height: 340 }, }) + expect(result.current.flow.getNode('loop-child')).toMatchObject({ + position: { x: 100, y: 120 }, + }) + expect(result.current.flow.getNode('top-node')).toMatchObject({ position: { x: 500, y: 100 } }) + expect(result.current.flow.getViewport()).toEqual({ x: 0, y: 0, zoom: 0.7 }) + await waitFor(() => + expect(store.getState().workflowHistory.workflowHistoryEvent).toBe('LayoutOrganize'), + ) + expect(store.getState().workflowHistory.nodes).toMatchObject(result.current.flow.getNodes()) + act(() => store.getState().flushPendingSync()) + expect(doSyncWorkflowDraft).toHaveBeenCalledTimes(1) + }) - expect(mockGetLayoutForChildNodes).not.toHaveBeenCalled() - expect(mockGetLayoutByELK).not.toHaveBeenCalled() - expect(result.current.graph.getState().getNodes()).toMatchObject(runtimeState.nodes) - expect(mockSaveStateToHistory).not.toHaveBeenCalled() - expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() - expect(mockSetViewport).not.toHaveBeenCalled() + it('preserves the graph, viewport, history and draft when read only', async () => { + const { result, store, doSyncWorkflowDraft } = renderOrganize(true) + const before = result.current.flow.getNodes() + const history = store.getState().workflowHistory + await act(async () => result.current.handleLayout()) + act(() => store.getState().flushPendingSync()) + + expect(result.current.flow.getNodes()).toEqual(before) + expect(result.current.flow.getViewport()).toEqual({ x: 40, y: 60, zoom: 1.2 }) + expect(store.getState().workflowHistory).toEqual(history) + expect(doSyncWorkflowDraft).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts index 1400371c554..53a3ab78146 100644 --- a/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts @@ -1,41 +1,15 @@ +import type { Shape as HooksStoreShape } from '../../../hooks-store/store' +import type { InputVar } from '../../../types' import type { StartNodeType } from '../types' -import type { InputVar, ValueSelector } from '@/app/components/workflow/types' import { act } from '@testing-library/react' -import { renderWorkflowHook as renderHook } from '@/app/components/workflow/__tests__/workflow-test-env' -import { BlockEnum, ChangeType, InputVarType } from '@/app/components/workflow/types' +import { useNodes, useReactFlow } from 'reactflow' +import { createEdge, createNode, createStartNode } from '../../../__tests__/fixtures' +import { renderWorkflowCanvasHook } from '../../../__tests__/workflow-test-env' +import { BlockEnum, ChangeType, InputVarType } from '../../../types' import useConfig from '../use-config' -const mockUseWorkflow = vi.hoisted(() => vi.fn()) -let chatflowMode = false -const mockUseNodeCrud = vi.hoisted(() => vi.fn()) -const mockUseInspectVarsCrud = vi.hoisted(() => vi.fn()) -const mockNotify = vi.hoisted(() => vi.fn()) - -vi.mock('../../../hooks/use-workflow', async (importOriginal) => { - const actual = await importOriginal() - - return { - ...actual, - useWorkflow: () => mockUseWorkflow(), - } -}) - -vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ - __esModule: true, - default: (...args: unknown[]) => mockUseNodeCrud(...args), -})) - -vi.mock('../../../hooks/use-inspect-vars-crud', () => ({ - __esModule: true, - default: (...args: unknown[]) => mockUseInspectVarsCrud(...args), -})) - -vi.mock('@/app/notifications', () => ({ - __esModule: true, - toast: { - error: (message: string) => mockNotify({ type: 'error', message }), - }, -})) +const { notify } = vi.hoisted(() => ({ notify: vi.fn() })) +vi.mock('@/app/notifications', () => ({ toast: { error: notify } })) const createInputVar = (overrides: Partial = {}): InputVar => ({ label: 'Question', @@ -45,242 +19,155 @@ const createInputVar = (overrides: Partial = {}): InputVar => ({ ...overrides, }) -const createPayload = (overrides: Partial = {}): StartNodeType => ({ - title: 'Start', - desc: '', - type: BlockEnum.Start, - variables: [ +const renderConfig = () => { + const variables = [ createInputVar(), - createInputVar({ - label: 'Age', - variable: 'age', - type: InputVarType.number, - required: false, + createInputVar({ label: 'Age', variable: 'age', type: InputVarType.number, required: false }), + ] + const start = createStartNode({ id: 'start-node', data: { variables } }) + const nodes = [ + start, + createNode({ + id: 'consumer', + data: { + variables: [ + { variable: 'question', value_selector: ['start-node', 'query'] }, + { variable: 'age', value_selector: ['start-node', 'age'] }, + ], + }, }), - ], - ...overrides, -}) + ] + // Inspector persistence is supplied by the editor owner through the hooks store. + const renameInspectVarName = vi + .fn() + .mockResolvedValue(undefined) + const deleteInspectVar = vi.fn().mockResolvedValue(undefined) + const deleteNodeInspectorVars = vi + .fn() + .mockResolvedValue(undefined) + const view = renderWorkflowCanvasHook( + () => { + const startNode = useNodes().find((node) => node.id === start.id)! + return { ...useConfig(start.id, startNode.data), flow: useReactFlow() } + }, + { + nodes, + edges: [createEdge({ source: start.id, target: 'consumer' })], + hooksStoreProps: { renameInspectVarName, deleteInspectVar, deleteNodeInspectorVars }, + initialStoreState: { + nodesWithInspectVars: [ + { + nodeId: start.id, + nodePayload: start.data, + nodeType: BlockEnum.Start, + title: 'Start', + vars: [ + { + id: 'inspect-query', + type: 'node', + name: 'query', + description: '', + selector: [start.id, 'query'], + value_type: 'string', + value: 'hello', + edited: false, + visible: true, + is_truncated: false, + }, + ], + }, + ], + }, + }, + ) + return { ...view, variables, renameInspectVarName, deleteInspectVar, deleteNodeInspectorVars } +} + +const removal = { index: 0, payload: { type: ChangeType.remove, payload: { beforeKey: 'query' } } } describe('start/use-config', () => { - const mockSetInputs = vi.fn() - const mockHandleOutVarRenameChange = vi.fn() - const mockIsVarUsedInNodes = vi.fn() - const mockRemoveUsedVarInNodes = vi.fn() - const mockDeleteNodeInspectorVars = vi.fn() - const mockRenameInspectVarName = vi.fn() - const mockDeleteInspectVar = vi.fn() - const toastSpy = mockNotify - let currentInputs: StartNodeType + it('renames the saved variable and its downstream references', () => { + const { result, variables, renameInspectVarName } = renderConfig() + const renamed = [createInputVar({ variable: 'prompt' }), variables[1]!] - beforeEach(() => { - vi.clearAllMocks() - currentInputs = createPayload() - - mockUseWorkflow.mockReturnValue({ - handleOutVarRenameChange: mockHandleOutVarRenameChange, - isVarUsedInNodes: mockIsVarUsedInNodes, - removeUsedVarInNodes: mockRemoveUsedVarInNodes, - }) - chatflowMode = false - mockUseNodeCrud.mockImplementation(() => ({ - inputs: currentInputs, - setInputs: mockSetInputs, - })) - mockUseInspectVarsCrud.mockReturnValue({ - deleteNodeInspectorVars: mockDeleteNodeInspectorVars, - renameInspectVarName: mockRenameInspectVarName, - nodesWithInspectVars: [ - { - nodeId: 'start-node', - vars: [{ id: 'inspect-query', name: 'query' }], - }, - ], - deleteInspectVar: mockDeleteInspectVar, - }) - mockIsVarUsedInNodes.mockReturnValue(false) - }) - - it('should rename variables and sync downstream variable references', () => { - const { result } = renderHook(() => useConfig('start-node', currentInputs), { - resource: { kind: 'app', appId: 'app-1', mode: chatflowMode ? 'advanced-chat' : 'workflow' }, - }) - const renamedList = [ - createInputVar({ - label: 'Question', - variable: 'prompt', - }), - createInputVar({ - label: 'Age', - variable: 'age', - type: InputVarType.number, - required: false, - }), - ] - - act(() => { - result.current.handleVarListChange(renamedList, { + act(() => + result.current.handleVarListChange(renamed, { index: 0, - payload: { - type: ChangeType.changeVarName, - payload: { - beforeKey: 'query', - }, - }, - }) - }) - - expect(mockSetInputs).toHaveBeenCalledWith( - expect.objectContaining({ - variables: renamedList, + payload: { type: ChangeType.changeVarName, payload: { beforeKey: 'query' } }, }), ) - expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith( - 'start-node', - ['start-node', 'query'], - ['start-node', 'prompt'], - ) - expect(mockRenameInspectVarName).toHaveBeenCalledWith('start-node', 'query', 'prompt') - expect(result.current.readOnly).toBe(false) - expect(result.current.isChatflow).toBe(false) + + expect(result.current.inputs.variables).toEqual(renamed) + expect(result.current.flow.getNode('consumer')?.data.variables).toEqual([ + { variable: 'question', value_selector: ['start-node', 'prompt'] }, + { variable: 'age', value_selector: ['start-node', 'age'] }, + ]) + expect(renameInspectVarName).toHaveBeenCalledExactlyOnceWith('start-node', 'query', 'prompt') }) - it('should block removal when the variable is still in use and confirm the deletion later', () => { - mockIsVarUsedInNodes.mockReturnValue(true) - const { result } = renderHook(() => useConfig('start-node', currentInputs), { - resource: { kind: 'app', appId: 'app-1', mode: chatflowMode ? 'advanced-chat' : 'workflow' }, - }) - const nextList = [currentInputs.variables[1]!] + it('keeps a referenced variable until confirmation, then removes it and clears its references', () => { + const { result, variables, deleteInspectVar } = renderConfig() + act(() => result.current.handleVarListChange([variables[1]!], removal)) - act(() => { - result.current.handleVarListChange(nextList, { - index: 0, - payload: { - type: ChangeType.remove, - payload: { - beforeKey: 'query', - }, - }, - }) - }) + expect(result.current.isShowRemoveVarConfirm).toBe(true) + expect(result.current.inputs.variables).toEqual(variables) + expect(deleteInspectVar).not.toHaveBeenCalled() - expect(mockDeleteInspectVar).not.toHaveBeenCalled() - expect(mockSetInputs).not.toHaveBeenCalled() + act(() => result.current.onRemoveVarConfirm()) + + expect(result.current.inputs.variables).toEqual([variables[1]]) + expect(result.current.flow.getNode('consumer')?.data.variables).toEqual([ + { variable: 'question', value_selector: [] }, + { variable: 'age', value_selector: ['start-node', 'age'] }, + ]) + expect(deleteInspectVar).toHaveBeenCalledExactlyOnceWith('start-node', 'inspect-query') + expect(result.current.isShowRemoveVarConfirm).toBe(false) + }) + + it('preserves the graph and inspector value when removal is cancelled', () => { + const { result, variables, deleteInspectVar } = renderConfig() + const before = result.current.flow.getNodes() + act(() => result.current.handleVarListChange([variables[1]!], removal)) expect(result.current.isShowRemoveVarConfirm).toBe(true) - act(() => { - result.current.onRemoveVarConfirm() - }) - - expect(mockSetInputs).toHaveBeenCalledWith( - expect.objectContaining({ - variables: [expect.objectContaining({ variable: 'age' })], - }), - ) - expect(mockDeleteInspectVar).toHaveBeenCalledExactlyOnceWith('start-node', 'inspect-query') - expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['start-node', 'query'] as ValueSelector) - expect(result.current.isShowRemoveVarConfirm).toBe(false) - }) - - it('keeps the variable and inspector value when removal is cancelled', () => { - mockIsVarUsedInNodes.mockReturnValue(true) - const { result } = renderHook(() => useConfig('start-node', currentInputs)) - act(() => - result.current.handleVarListChange([currentInputs.variables[1]!], { - index: 0, - payload: { type: ChangeType.remove, payload: { beforeKey: 'query' } }, - }), - ) act(() => result.current.hideRemoveVarConfirm()) + expect(result.current.isShowRemoveVarConfirm).toBe(false) - expect(mockSetInputs).not.toHaveBeenCalled() - expect(mockDeleteInspectVar).not.toHaveBeenCalled() - expect(mockRemoveUsedVarInNodes).not.toHaveBeenCalled() + expect(result.current.flow.getNodes()).toEqual(before) + expect(deleteInspectVar).not.toHaveBeenCalled() }) - it('should validate duplicate variables and labels before adding a new variable', () => { - const { result } = renderHook(() => useConfig('start-node', currentInputs), { - resource: { kind: 'app', appId: 'app-1', mode: chatflowMode ? 'advanced-chat' : 'workflow' }, - }) - - let added = true + it('rejects a duplicate key without changing the node, then saves a unique variable', () => { + const { result, variables } = renderConfig() act(() => { - added = result.current.handleAddVariable( - createInputVar({ - label: 'Different Label', - variable: 'query', - }), + expect(result.current.handleAddVariable(createInputVar({ label: 'Different label' }))).toBe( + false, ) }) + expect(result.current.inputs.variables).toEqual(variables) + expect(notify).toHaveBeenCalledWith(expect.stringContaining('varKeyError.keyAlreadyExists')) - expect(added).toBe(false) - expect(toastSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: expect.stringContaining('varKeyError.keyAlreadyExists'), - }), - ) - - mockSetInputs.mockClear() - let addedUnique = false + const locale = createInputVar({ label: 'Locale', variable: 'locale', required: false }) act(() => { - addedUnique = result.current.handleAddVariable( - createInputVar({ - label: 'Locale', - variable: 'locale', - required: false, - }), - ) + expect(result.current.handleAddVariable(locale)).toBe(true) }) - - expect(addedUnique).toBe(true) - expect(mockSetInputs).toHaveBeenCalledWith( - expect.objectContaining({ - variables: expect.arrayContaining([expect.objectContaining({ variable: 'locale' })]), - }), - ) + expect(result.current.inputs.variables).toEqual([...variables, locale]) }) - it('should clear inspector vars for non-remove list updates and reject duplicate labels', () => { - const { result } = renderHook(() => useConfig('start-node', currentInputs), { - resource: { kind: 'app', appId: 'app-1', mode: chatflowMode ? 'advanced-chat' : 'workflow' }, - }) - const typeEditedList = [ - createInputVar({ - label: 'Question', - variable: 'query', - type: InputVarType.paragraph, - }), - currentInputs.variables[1]!, - ] + it('saves a type edit and clears inspector values, but rejects a duplicate label', () => { + const { result, variables, deleteNodeInspectorVars } = renderConfig() + const edited = [createInputVar({ type: InputVarType.paragraph }), variables[1]!] + act(() => result.current.handleVarListChange(edited)) + + expect(result.current.inputs.variables).toEqual(edited) + expect(deleteNodeInspectorVars).toHaveBeenCalledExactlyOnceWith('start-node') act(() => { - result.current.handleVarListChange(typeEditedList) + expect( + result.current.handleAddVariable(createInputVar({ label: 'Age', variable: 'new_age' })), + ).toBe(false) }) - - expect(mockSetInputs).toHaveBeenCalledWith( - expect.objectContaining({ - variables: typeEditedList, - }), - ) - expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('start-node') - - toastSpy.mockClear() - let added = true - act(() => { - added = result.current.handleAddVariable( - createInputVar({ - label: 'Age', - variable: 'new_age', - }), - ) - }) - - expect(added).toBe(false) - expect(toastSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: expect.stringContaining('varKeyError.keyAlreadyExists'), - }), - ) + expect(result.current.inputs.variables).toEqual(edited) + expect(notify).toHaveBeenCalledWith(expect.stringContaining('varKeyError.keyAlreadyExists')) }) }) diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx index ecd2473134d..d35d3e7b167 100644 --- a/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx +++ b/web/app/components/workflow/workflow-preview/components/__tests__/zoom-in-out.spec.tsx @@ -1,108 +1,75 @@ -import { render, screen, within } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { createNode } from '../../../__tests__/fixtures' +import { renderWorkflowFlowComponent } from '../../../__tests__/workflow-test-env' import ZoomInOut from '../zoom-in-out' -const { mockZoomIn, mockZoomOut, mockZoomTo, mockFitView, mockViewport } = vi.hoisted(() => ({ - mockZoomIn: vi.fn(), - mockZoomOut: vi.fn(), - mockZoomTo: vi.fn(), - mockFitView: vi.fn(), - mockViewport: { zoom: 1 }, -})) - -vi.mock('reactflow', () => ({ - useReactFlow: () => ({ - zoomIn: mockZoomIn, - zoomOut: mockZoomOut, - zoomTo: mockZoomTo, - fitView: mockFitView, - }), - useViewport: () => mockViewport, -})) - -const getZoomControls = () => ({ - zoomOutTrigger: screen.getByRole('button', { name: 'workflow.operator.zoomOut' }), - label: screen.getByRole('button', { name: /^\d+%$/ }), - zoomInTrigger: screen.getByRole('button', { name: 'workflow.operator.zoomIn' }), -}) +const renderZoom = (zoom = 1) => + renderWorkflowFlowComponent(, { + nodes: [ + createNode({ id: 'left', position: { x: 0, y: 0 }, width: 240, height: 80 }), + createNode({ id: 'right', position: { x: 1600, y: 400 }, width: 240, height: 80 }), + ], + reactFlowProps: { + fitView: false, + minZoom: 0.25, + maxZoom: 2, + defaultViewport: { x: 0, y: 0, zoom }, + }, + }) +const zoomLabel = () => screen.getByRole('button', { name: /^\d+%$/ }) const openZoomMenu = async (user: ReturnType) => { - await user.click(getZoomControls().label) + await user.click(zoomLabel()) return within(await screen.findByRole('menu')) } describe('workflow preview zoom controls', () => { - beforeEach(() => { - vi.clearAllMocks() - mockViewport.zoom = 1 + it('zooms out and in and displays the resulting viewport scale', async () => { + const user = userEvent.setup() + renderZoom() + + await user.click(screen.getByRole('button', { name: 'workflow.operator.zoomOut' })) + await waitFor(() => expect(zoomLabel()).toHaveAccessibleName('83%')) + await user.click(screen.getByRole('button', { name: 'workflow.operator.zoomIn' })) + await waitFor(() => expect(zoomLabel()).toHaveAccessibleName('100%')) }) - // The inline controls should call the reactflow zoom handlers when the viewport is within range. - describe('Inline Controls', () => { - it('should zoom out and zoom in when the viewport is within the supported range', async () => { - const user = userEvent.setup() - render() + it.each([ + { zoom: 0.25, control: 'workflow.operator.zoomOut', label: '25%' }, + { zoom: 2, control: 'workflow.operator.zoomIn', label: '200%' }, + ])('disables $control at $label', async ({ zoom, control, label }) => { + const user = userEvent.setup() + renderZoom(zoom) + const button = screen.getByRole('button', { name: control }) - const { zoomOutTrigger, zoomInTrigger } = getZoomControls() - - await user.click(zoomOutTrigger) - await user.click(zoomInTrigger) - - expect(mockZoomOut).toHaveBeenCalledTimes(1) - expect(mockZoomIn).toHaveBeenCalledTimes(1) - }) - - it('should block zooming out when the viewport is already at the minimum scale', async () => { - const user = userEvent.setup() - mockViewport.zoom = 0.25 - render() - - await user.click(getZoomControls().zoomOutTrigger) - - expect(mockZoomOut).not.toHaveBeenCalled() - }) - - it('should block zooming in when the viewport is already at the maximum scale', async () => { - const user = userEvent.setup() - mockViewport.zoom = 2 - render() - - await user.click(getZoomControls().zoomInTrigger) - - expect(mockZoomIn).not.toHaveBeenCalled() - }) + expect(button).toBeDisabled() + await user.click(button) + expect(zoomLabel()).toHaveAccessibleName(label) }) - // Preset menu actions should route to the correct reactflow commands. - describe('Preset Menu', () => { - it.each([ - ['25%', 0.25], - ['50%', 0.5], - ['75%', 0.75], - ['100%', 1], - ['200%', 2], - ])('should zoom to %s when selecting that preset', async (label, zoom) => { + it.each(['25%', '50%', '75%', '100%', '200%'])( + 'applies the %s preset from a click', + async (label) => { const user = userEvent.setup() - render() + renderZoom(label === '100%' ? 0.75 : 1) + const menu = await openZoomMenu(user) - const portal = await openZoomMenu(user) + await user.click(menu.getByRole('menuitem', { name: new RegExp(`^${label}`) })) - await user.click(portal.getByRole('menuitem', { name: new RegExp(label) })) + await waitFor(() => expect(zoomLabel()).toHaveAccessibleName(label)) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }, + ) - expect(mockZoomTo).toHaveBeenCalledWith(zoom) - expect(mockFitView).not.toHaveBeenCalled() - }) + it('fits the graph from the menu', async () => { + const user = userEvent.setup() + renderZoom() + const menu = await openZoomMenu(user) - it('should fit the viewport when selecting the fit option', async () => { - const user = userEvent.setup() - render() + await user.click(menu.getByRole('menuitem', { name: /^workflow\.operator\.zoomToFit/ })) - const portal = await openZoomMenu(user) - - await user.click(portal.getByRole('menuitem', { name: /workflow\.operator\.zoomToFit/ })) - - expect(mockFitView).toHaveBeenCalledTimes(1) - expect(mockZoomTo).not.toHaveBeenCalled() - }) + await waitFor(() => expect(zoomLabel()).toHaveAccessibleName('25%')) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) })