test(web): exercise workflow configuration and controls with real stores (#44471)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-21 05:10:54 +00:00
1 parent f0daa16723
commit 796cc1bfbb
4 files changed
+354 -579

No files matched your search

@@ -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, string>) => string,
options?: Record<string, unknown>,
) => {
const key = selector(
new Proxy({}, { get: (_, property: string) => property }) as Record<string, string>,
)
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<typeof renderLocate>) =>
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([])
})
})
@@ -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<typeof createNode>[],
edges: [] as { id: string; source: string; target: string }[],
nodesReadOnly: false,
}))
vi.mock('reactflow', async (importOriginal) => {
const actual = await importOriginal<typeof import('reactflow')>()
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<typeof import('../../utils/elk-layout')>()),
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<HooksStoreShape['doSyncWorkflowDraft']>()
.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()
})
})
@@ -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<typeof import('../../../hooks/use-workflow')>()
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> = {}): InputVar => ({
label: 'Question',
@@ -45,242 +19,155 @@ const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
...overrides,
})
const createPayload = (overrides: Partial<StartNodeType> = {}): 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<HooksStoreShape['renameInspectVarName']>()
.mockResolvedValue(undefined)
const deleteInspectVar = vi.fn<HooksStoreShape['deleteInspectVar']>().mockResolvedValue(undefined)
const deleteNodeInspectorVars = vi
.fn<HooksStoreShape['deleteNodeInspectorVars']>()
.mockResolvedValue(undefined)
const view = renderWorkflowCanvasHook(
() => {
const startNode = useNodes<StartNodeType>().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'))
})
})
@@ -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(<ZoomInOut />, {
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<typeof userEvent.setup>) => {
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(<ZoomInOut />)
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(<ZoomInOut />)
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(<ZoomInOut />)
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(<ZoomInOut />)
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(<ZoomInOut />)
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()
})
})