diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts deleted file mode 100644 index 4c79f53bb63..00000000000 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { AppMode } from '@dify/contracts/api/console/apps/types.gen' -import type { ReactNode } from 'react' -import { act } from '@testing-library/react' -import { createElement } from 'react' -import { createNode } from '@/app/components/workflow/__tests__/fixtures' -import { createTestWorkflowStore } from '@/app/components/workflow/__tests__/workflow-test-env' -import { WorkflowContext } from '@/app/components/workflow/context' -import { renderEditorHook } from '@/app/components/workflow/editor-resource/__tests__/render' -import { BlockEnum } from '@/app/components/workflow/types' -import { createConsoleQueryClient, seedAppDetail } from '@/test/console/query-data' -import { createDeferred } from '@/test/deferred' -import { createAppDetailFixture } from '@/test/fixtures/app' -import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft' - -const { updateCanvas, fetchDraft } = vi.hoisted(() => ({ - updateCanvas: vi.fn(), - fetchDraft: vi.fn(), -})) -vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ - useWorkflowUpdate: () => ({ handleUpdateWorkflowCanvas: updateCanvas }), -})) -vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: fetchDraft })) - -const draftResponse = { - hash: 'server-hash', - graph: { - nodes: [createNode({ id: 'n1', data: { type: BlockEnum.Start } })], - edges: [], - viewport: { x: 1, y: 2, zoom: 1 }, - }, - environment_variables: [], - conversation_variables: [], -} -const setup = (mode: AppMode = 'advanced-chat') => { - const store = createTestWorkflowStore({ - isWorkflowDataLoaded: true, - syncWorkflowDraftHash: 'local-hash', - }) - const queryClient = createConsoleQueryClient() - seedAppDetail(queryClient, createAppDetailFixture({ id: 'app-1', mode })) - const hook = renderEditorHook(() => useWorkflowRefreshDraft(), { - queryClient, - resource: { kind: 'app', appId: 'app-1', mode: mode === 'advanced-chat' ? mode : 'workflow' }, - wrapper: ({ children }: { children: ReactNode }) => - createElement(WorkflowContext, { value: store }, children), - }) - return { ...hook, store } -} - -describe('useWorkflowRefreshDraft', () => { - beforeEach(() => { - updateCanvas.mockReset() - fetchDraft.mockReset().mockResolvedValue(draftResponse) - }) - - it.each([undefined, false, true])( - 'refreshes draft metadata and respects notUpdateCanvas=%s', - async (notUpdateCanvas) => { - const { result, store } = setup() - await act(async () => { - expect(await result.current.handleRefreshWorkflowDraft(notUpdateCanvas)).toBe(true) - }) - expect(fetchDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft') - if (notUpdateCanvas) expect(updateCanvas).not.toHaveBeenCalled() - else expect(updateCanvas).toHaveBeenCalledExactlyOnceWith(draftResponse.graph) - expect(store.getState()).toMatchObject({ - syncWorkflowDraftHash: 'server-hash', - isSyncingWorkflowDraft: false, - isWorkflowDataLoaded: true, - }) - }, - ) - - it('discards a stale guarded response before it mutates the canvas or draft metadata', async () => { - const deferred = createDeferred() - fetchDraft.mockReturnValue(deferred.promise) - let isCurrent = true - const { result, store } = setup() - let refresh!: Promise - act(() => { - refresh = result.current.handleRefreshWorkflowDraft(false, { shouldApply: () => isCurrent }) - }) - isCurrent = false - await act(async () => { - deferred.resolve(draftResponse) - expect(await refresh).toBe(false) - }) - expect(updateCanvas).not.toHaveBeenCalled() - expect(store.getState()).toMatchObject({ - syncWorkflowDraftHash: 'local-hash', - environmentVariables: [], - conversationVariables: [], - }) - }) - - it('keeps the syncing guard active until the newest overlapping refresh completes', async () => { - const first = createDeferred() - const second = createDeferred() - fetchDraft.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) - let firstIsCurrent = true - const { result, store } = setup() - let firstRefresh!: Promise - let secondRefresh!: Promise - act(() => { - firstRefresh = result.current.handleRefreshWorkflowDraft(false, { - shouldApply: () => firstIsCurrent, - }) - secondRefresh = result.current.handleRefreshWorkflowDraft(false, { shouldApply: () => true }) - }) - firstIsCurrent = false - await act(async () => { - first.resolve(draftResponse) - await firstRefresh - }) - expect(store.getState().isSyncingWorkflowDraft).toBe(true) - await act(async () => { - second.resolve(draftResponse) - await secondRefresh - }) - expect(store.getState().isSyncingWorkflowDraft).toBe(false) - expect(updateCanvas).toHaveBeenCalledOnce() - }) - - it('cancels pending sync, restores the viewport, and separates secret values from displayed variables', async () => { - const { result, store } = setup() - const cancel = vi.spyOn(store.getState().debouncedSyncWorkflowDraft, 'cancel') - const conversationVariables = [ - { id: 'conversation-1', name: 'name', value_type: 'string', value: 'Alice', description: '' }, - ] - fetchDraft.mockResolvedValue({ - ...draftResponse, - graph: { nodes: draftResponse.graph.nodes, edges: [] }, - environment_variables: [ - { id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' }, - { id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' }, - ], - conversation_variables: conversationVariables, - }) - await act(async () => { - await result.current.handleRefreshWorkflowDraft() - }) - expect(cancel).toHaveBeenCalledOnce() - expect(updateCanvas).toHaveBeenCalledWith({ - nodes: draftResponse.graph.nodes, - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }) - expect(store.getState()).toMatchObject({ - envSecrets: { 'env-secret': 'top-secret' }, - environmentVariables: [ - { id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' }, - { id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' }, - ], - conversationVariables, - }) - cancel.mockRestore() - }) - - it.each(['workflow', 'advanced-chat'] as const)( - 'restores the appropriate empty canvas for %s', - async (mode) => { - fetchDraft.mockResolvedValue({ ...draftResponse, graph: { nodes: [], edges: [] } }) - const { result } = setup(mode) - await act(async () => { - await result.current.handleRefreshWorkflowDraft() - }) - expect(updateCanvas).toHaveBeenCalledWith({ - nodes: - mode === 'workflow' - ? [ - expect.objectContaining({ - data: expect.objectContaining({ - type: BlockEnum.StartPlaceholder, - selected: true, - }), - }), - ] - : [], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }) - }, - ) - - it('restores the previously loaded state and releases the sync guard after a failed refresh', async () => { - const deferred = createDeferred() - fetchDraft.mockReturnValue(deferred.promise) - const { result, store } = setup() - let refresh!: Promise - act(() => { - refresh = result.current.handleRefreshWorkflowDraft() - }) - expect(store.getState()).toMatchObject({ - isWorkflowDataLoaded: false, - isSyncingWorkflowDraft: true, - }) - await act(async () => { - deferred.reject(new Error('refresh failed')) - expect(await refresh).toBe(false) - }) - expect(store.getState()).toMatchObject({ - isWorkflowDataLoaded: true, - isSyncingWorkflowDraft: false, - syncWorkflowDraftHash: 'local-hash', - }) - expect(updateCanvas).not.toHaveBeenCalled() - }) -}) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.tsx b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.tsx new file mode 100644 index 00000000000..3061e5f6645 --- /dev/null +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.tsx @@ -0,0 +1,374 @@ +import type { AppMode } from '@dify/contracts/api/console/apps/types.gen' +import type { ReactFlowInstance } from 'reactflow' +import type { FetchWorkflowDraftResponse } from '@/types/workflow' +import { act, waitFor } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRef, useImperativeHandle } from 'react' +import { useReactFlow } from 'reactflow' +import WorkflowWithDefaultContext, { Workflow } from '@/app/components/workflow' +import { + createEdge, + createNode, + createStartNode, +} from '@/app/components/workflow/__tests__/fixtures' +import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { BlockEnum } from '@/app/components/workflow/types' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' +import { consoleQuery } from '@/service/console' +import { seedModelProviderQueries } from '@/test/console/model-provider' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { seedToolProviders } from '@/test/console/tool-providers' +import { createDeferred } from '@/test/deferred' +import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft' + +const { fetchDraft } = vi.hoisted(() => ({ + fetchDraft: vi.fn<(url: string) => Promise>(), +})) + +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useSearchParams: () => new URLSearchParams(), + usePathname: () => '/app/app-1/workflow', + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})) + +vi.mock('@/service/workflow', async (importOriginal) => ({ + ...(await importOriginal()), + fetchWorkflowDraft: fetchDraft, + fetchAllInspectVars: async () => [], +})) + +const localNodes = [ + createStartNode({ + id: 'local-start', + width: 240, + height: 100, + data: { title: 'Local start', variables: [] }, + }), + createNode({ + id: 'local-code', + width: 240, + height: 100, + position: { x: 320, y: 0 }, + data: { type: BlockEnum.Code, title: 'Local code' }, + }), +] +const localEdges = [createEdge({ id: 'local-edge', source: 'local-start', target: 'local-code' })] +const localViewport = { x: 80, y: 60, zoom: 0.8 } + +const savedNodes = [ + createStartNode({ + id: 'saved-start', + width: 240, + height: 100, + data: { title: 'Saved start', variables: [] }, + }), + createNode({ + id: 'saved-code', + width: 240, + height: 100, + position: { x: 360, y: 20 }, + data: { type: BlockEnum.Code, title: 'Saved code' }, + }), +] +const savedEdges = [createEdge({ id: 'saved-edge', source: 'saved-start', target: 'saved-code' })] +const savedViewport = { x: 10, y: 20, zoom: 1.2 } +const draftResponse: FetchWorkflowDraftResponse = { + id: 'draft-1', + hash: 'server-hash', + graph: { nodes: savedNodes, edges: savedEdges, viewport: savedViewport }, + created_at: 1, + created_by: { id: 'user-1', name: 'User', email: 'user@example.com' }, + updated_at: 2, + updated_by: { id: 'user-1', name: 'User', email: 'user@example.com' }, + tool_published: false, + environment_variables: [], + conversation_variables: [], + version: 'draft', + marked_name: '', + marked_comment: '', +} + +function setup(mode: AppMode = 'advanced-chat') { + const { queryClient } = createConsoleQueryWrapper({ + systemFeatures: { enable_collaboration_mode: false }, + appDslVersion: '0.6.0', + }) + seedModelProviderQueries(queryClient) + seedToolProviders(queryClient, { + builtInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + }) + queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), []) + queryClient.setQueryData(consoleQuery.workspaces.current.agentProviders.get.queryKey(), []) + queryClient.setQueryData(consoleQuery.workspaces.current.triggers.get.queryKey({ input: {} }), []) + + const refresh = createRef>() + const flow = createRef() + function RefreshProbe() { + const hook = useWorkflowRefreshDraft() + const canvas = useReactFlow() + useImperativeHandle(refresh, () => hook, [hook]) + useImperativeHandle(flow, () => canvas, [canvas]) + return null + } + + const rendered = renderWorkflowComponent( + + + + + + + + + , + { + queryClient, + resource: { kind: 'app', appId: 'app-1', mode: mode === 'advanced-chat' ? mode : 'workflow' }, + initialStoreState: { + nodes: localNodes, + isWorkflowDataLoaded: true, + syncWorkflowDraftHash: 'local-hash', + }, + }, + ) + + return { ...rendered, flow, refresh } +} + +async function expectGraph( + flow: React.RefObject, + nodeIds: string[], + edgeIds: string[], + viewport: { x: number; y: number; zoom: number }, +) { + await waitFor(() => { + expect(flow.current!.getNodes().map((node) => node.id)).toEqual(nodeIds) + expect(flow.current!.getEdges().map((edge) => edge.id)).toEqual(edgeIds) + expect(flow.current!.getViewport()).toEqual(viewport) + }) +} + +describe('useWorkflowRefreshDraft', () => { + beforeEach(() => { + fetchDraft.mockReset().mockResolvedValue(draftResponse) + vi.stubGlobal( + 'fetch', + vi.fn(() => { + throw new Error('Unexpected network request') + }), + ) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it.each([undefined, false, true])( + 'refreshes draft metadata and respects notUpdateCanvas=%s', + async (notUpdateCanvas) => { + const { flow, refresh, store } = setup() + await expectGraph(flow, ['local-start', 'local-code'], ['local-edge'], localViewport) + + await act(async () => { + expect(await refresh.current!.handleRefreshWorkflowDraft(notUpdateCanvas)).toBe(true) + }) + + expect(fetchDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft') + await expectGraph( + flow, + notUpdateCanvas ? ['local-start', 'local-code'] : ['saved-start', 'saved-code'], + notUpdateCanvas ? ['local-edge'] : ['saved-edge'], + notUpdateCanvas ? localViewport : savedViewport, + ) + expect(store.getState()).toMatchObject({ + syncWorkflowDraftHash: 'server-hash', + isSyncingWorkflowDraft: false, + isWorkflowDataLoaded: true, + }) + }, + ) + + it('discards a stale guarded response before it mutates the graph or draft metadata', async () => { + const deferred = createDeferred() + fetchDraft.mockReturnValue(deferred.promise) + let isCurrent = true + const { flow, refresh, store } = setup() + let request!: Promise + act(() => { + request = refresh.current!.handleRefreshWorkflowDraft(false, { + shouldApply: () => isCurrent, + }) + }) + isCurrent = false + + await act(async () => { + deferred.resolve(draftResponse) + expect(await request).toBe(false) + }) + + await expectGraph(flow, ['local-start', 'local-code'], ['local-edge'], localViewport) + expect(store.getState()).toMatchObject({ + syncWorkflowDraftHash: 'local-hash', + environmentVariables: [], + conversationVariables: [], + }) + }) + + it('keeps the syncing guard active until the newest overlapping refresh completes', async () => { + const first = createDeferred() + const second = createDeferred() + fetchDraft.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + let firstIsCurrent = true + const { flow, refresh, store } = setup() + let firstRequest!: Promise + let secondRequest!: Promise + act(() => { + firstRequest = refresh.current!.handleRefreshWorkflowDraft(false, { + shouldApply: () => firstIsCurrent, + }) + secondRequest = refresh.current!.handleRefreshWorkflowDraft(false, { + shouldApply: () => true, + }) + }) + firstIsCurrent = false + + await act(async () => { + first.resolve(draftResponse) + await firstRequest + }) + expect(store.getState().isSyncingWorkflowDraft).toBe(true) + await expectGraph(flow, ['local-start', 'local-code'], ['local-edge'], localViewport) + + await act(async () => { + second.resolve(draftResponse) + await secondRequest + }) + expect(store.getState().isSyncingWorkflowDraft).toBe(false) + await expectGraph(flow, ['saved-start', 'saved-code'], ['saved-edge'], savedViewport) + }) + + it('cancels pending sync, restores the default viewport, and separates secret values', async () => { + const { flow, refresh, store } = setup() + const pendingSyncEffect = vi.fn() + vi.useFakeTimers() + store.getState().debouncedSyncWorkflowDraft(pendingSyncEffect) + const conversationVariables = [ + { id: 'conversation-1', name: 'name', value_type: 'string', value: 'Alice', description: '' }, + ] satisfies NonNullable + fetchDraft.mockResolvedValue({ + ...draftResponse, + graph: { nodes: savedNodes, edges: savedEdges }, + environment_variables: [ + { + id: 'env-secret', + value_type: 'secret', + value: 'top-secret', + name: 'SECRET', + description: '', + }, + { + id: 'env-plain', + value_type: 'string', + value: 'visible', + name: 'PLAIN', + description: '', + }, + ], + conversation_variables: conversationVariables, + }) + + await act(async () => { + await refresh.current!.handleRefreshWorkflowDraft() + }) + act(() => vi.advanceTimersByTime(5000)) + vi.useRealTimers() + + expect(pendingSyncEffect).not.toHaveBeenCalled() + await expectGraph(flow, ['saved-start', 'saved-code'], ['saved-edge'], { + x: 0, + y: 0, + zoom: 1, + }) + expect(store.getState()).toMatchObject({ + envSecrets: { 'env-secret': 'top-secret' }, + environmentVariables: [ + { + id: 'env-secret', + value_type: 'secret', + value: '[__HIDDEN__]', + name: 'SECRET', + description: '', + }, + { + id: 'env-plain', + value_type: 'string', + value: 'visible', + name: 'PLAIN', + description: '', + }, + ], + conversationVariables, + }) + }) + + it.each(['workflow', 'advanced-chat'] as const)( + 'restores the appropriate empty canvas for %s', + async (mode) => { + fetchDraft.mockResolvedValue({ ...draftResponse, graph: { nodes: [], edges: [] } }) + const { flow, refresh } = setup(mode) + + await act(async () => { + await refresh.current!.handleRefreshWorkflowDraft() + }) + + await waitFor(() => { + const nodes = flow.current!.getNodes() + if (mode === 'workflow') { + expect(nodes).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ + type: BlockEnum.StartPlaceholder, + selected: true, + }), + }), + ]) + } else { + expect(nodes).toEqual([]) + } + expect(flow.current!.getEdges()).toEqual([]) + expect(flow.current!.getViewport()).toEqual({ x: 0, y: 0, zoom: 1 }) + }) + }, + ) + + it('restores the previously loaded state and releases the sync guard after failure', async () => { + const deferred = createDeferred() + fetchDraft.mockReturnValue(deferred.promise) + const { flow, refresh, store } = setup() + let request!: Promise + act(() => { + request = refresh.current!.handleRefreshWorkflowDraft() + }) + expect(store.getState()).toMatchObject({ + isWorkflowDataLoaded: false, + isSyncingWorkflowDraft: true, + }) + + await act(async () => { + deferred.reject(new Error('refresh failed')) + expect(await request).toBe(false) + }) + + expect(store.getState()).toMatchObject({ + isWorkflowDataLoaded: true, + isSyncingWorkflowDraft: false, + syncWorkflowDraftHash: 'local-hash', + }) + await expectGraph(flow, ['local-start', 'local-code'], ['local-edge'], localViewport) + }) +}) diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx index bdeec5f0d4c..dfb80c99da5 100644 --- a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx @@ -1,108 +1,121 @@ -import type { Node } from '../types' import { ContextMenu } from '@langgenius/dify-ui/context-menu' import { detectPlatform } from '@tanstack/react-hotkeys' -import { act, screen } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { ReactFlowProvider } from 'reactflow' +import { useState } from 'react' +import { seedModelProviderQueries } from '@/test/console/model-provider' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { seedToolProviders } from '@/test/console/tool-providers' +import { fullWorkflowAccessControl } from '../hooks-store' import { NodeContextmenu } from '../node-contextmenu' +import { BlockEnum } from '../types' +import { CANVAS_NODES_STATUS_NAME, CanvasNodesCatalogProbe } from './canvas-nodes-catalog-probe' import { createNode } from './fixtures' -import { renderWorkflowComponent } from './workflow-test-env' +import { renderWorkflowFlowComponent } from './workflow-test-env' -const mockUseNodeActionsMenuModel = vi.hoisted(() => vi.fn()) -const copy = vi.fn() -const duplicate = vi.fn() -const removeNode = vi.fn() +function ControlledNodeMenu({ onClose }: { onClose: () => void }) { + const [open, setOpen] = useState(true) -vi.mock('@/app/components/workflow/node-actions-menu/use-node-actions-menu-model', () => ({ - useNodeActionsMenuModel: (props: unknown) => mockUseNodeActionsMenuModel(props), -})) + return ( + <> + + { + setOpen(false) + onClose() + }} + /> + + + + ) +} + +function renderNodeContextmenu(nodeId?: string) { + const nodes = [ + createNode({ + id: 'node-1', + width: 240, + height: 80, + data: { type: BlockEnum.Code, title: 'Node 1' }, + }), + ] + const { queryClient } = createConsoleQueryWrapper({ appDslVersion: '0.6.0' }) + seedModelProviderQueries(queryClient) + seedToolProviders(queryClient, { + builtInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + }) + const onClose = vi.fn() + const rendered = renderWorkflowFlowComponent(, { + nodes, + edges: [], + queryClient, + hooksStoreProps: { + accessControl: fullWorkflowAccessControl, + doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), + }, + initialStoreState: nodeId + ? { nodes, contextMenuTarget: { type: 'node', nodeId } } + : { nodes, contextMenuTarget: undefined }, + reactFlowProps: { fitView: false }, + }) + + return { ...rendered, onClose } +} + +function canvasNodeIds() { + return ( + screen.getByRole('status', { name: CANVAS_NODES_STATUS_NAME }).textContent?.split(',') ?? [] + ).filter(Boolean) +} describe('NodeContextmenu', () => { - const mockClose = vi.fn() - let contextMenuTarget: { type: 'node'; nodeId: string } | undefined - let nodes: Node[] - - beforeEach(() => { - vi.clearAllMocks() - contextMenuTarget = undefined - nodes = [createNode({ id: 'node-1', data: { title: 'Node 1' } })] - mockUseNodeActionsMenuModel.mockImplementation( - (props: { id: string; data: Node['data']; onClose: () => void }) => ({ - about: { - author: 'Dify', - description: 'Node actions', - }, - canChangeBlock: false, - canRun: false, - data: props.data, - handleCopy: () => { - copy() - props.onClose() - }, - handleDelete: () => { - removeNode() - props.onClose() - }, - handleDuplicate: () => { - duplicate() - props.onClose() - }, - handleRun: props.onClose, - helpLinkUri: undefined, - id: props.id, - isSingleton: false, - isUndeletable: false, - nodesReadOnly: false, - sourceHandle: 'source', - workflowAppHref: undefined, - }), - ) - }) - - const renderNodeContextmenu = () => - renderWorkflowComponent( - - - - - , - { initialStoreState: { nodes, contextMenuTarget } }, - ) - it.each([ - ['c', copy], - ['d', duplicate], - ['Delete', removeNode], - ] as const)( - 'routes %s from the node context menu to its matching action', - async (key, handler) => { - const user = userEvent.setup() - contextMenuTarget = { type: 'node', nodeId: 'node-1' } - renderNodeContextmenu() - const item = screen.getByRole('menuitem', { name: /workflow.common.copy/ }) - act(() => item.focus()) - const mod = detectPlatform() === 'mac' ? 'Meta' : 'Control' - await user.keyboard(key.length === 1 ? `{${mod}>}${key}{/${mod}}` : `{${key}}`) - expect(mockClose).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledOnce() - for (const other of [copy, duplicate, removeNode].filter((action) => action !== handler)) - expect(other).not.toHaveBeenCalled() - }, - ) + { key: 'c', action: 'copy' }, + { key: 'd', action: 'duplicate' }, + { key: 'Delete', action: 'delete' }, + ] as const)('routes $key through the real $action action', async ({ key, action }) => { + const user = userEvent.setup() + const { onClose, store } = renderNodeContextmenu('node-1') + await screen.findByRole('menu') + await user.keyboard('{ArrowDown}') - it('should stay hidden when the node menu is absent', () => { - renderNodeContextmenu() + const mod = detectPlatform() === 'mac' ? 'Meta' : 'Control' + await user.keyboard(key.length === 1 ? `{${mod}>}${key}{/${mod}}` : `{${key}}`) - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - expect(mockUseNodeActionsMenuModel).not.toHaveBeenCalled() + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()) + expect(onClose).toHaveBeenCalledOnce() + + if (action === 'copy') { + expect(store.getState().clipboardElements.map((node) => node.id)).toEqual(['node-1']) + await waitFor(async () => { + const clipboard = JSON.parse(await navigator.clipboard.readText()) as { + nodes: Array<{ id: string }> + } + expect(clipboard.nodes.map((node) => node.id)).toEqual(['node-1']) + }) + expect(canvasNodeIds()).toEqual(['node-1']) + } else if (action === 'duplicate') { + await waitFor(() => expect(canvasNodeIds()).toHaveLength(2)) + expect(canvasNodeIds()).toContain('node-1') + expect(new Set(canvasNodeIds())).toHaveLength(2) + } else { + await waitFor(() => expect(canvasNodeIds()).toEqual([])) + } }) - it('should stay hidden when the referenced node cannot be found', () => { - contextMenuTarget = { type: 'node', nodeId: 'missing-node' } - + it('stays hidden when the node menu is absent', () => { renderNodeContextmenu() expect(screen.queryByRole('menu')).not.toBeInTheDocument() - expect(mockUseNodeActionsMenuModel).not.toHaveBeenCalled() + }) + + it('stays hidden when the referenced node cannot be found', () => { + renderNodeContextmenu('missing-node') + + expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx index c151370b9cf..c221cd50b95 100644 --- a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx @@ -1,143 +1,229 @@ -import type { ChecklistItem } from '../../../hooks/use-checklist' -import { screen } from '@testing-library/react' +import type { AgentSoulDifyToolConfig } from '@dify/contracts/api/console/apps/types.gen' +import type { Edge, Node } from '../../../types' +import { zWorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/zod.gen' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { CollectionType } from '@/app/components/tools/types' +import { consoleQuery } from '@/service/console' +import { seedModelProviderQueries } from '@/test/console/model-provider' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { seedToolProviders } from '@/test/console/tool-providers' +import { + CANVAS_NODES_STATUS_NAME, + CanvasNodesCatalogProbe, +} from '../../../__tests__/canvas-nodes-catalog-probe' +import { createEdge, createNode, createStartNode } from '../../../__tests__/fixtures' import { renderWorkflowFlowComponent as render } from '../../../__tests__/workflow-test-env' +import DatasetsDetailProvider from '../../../datasets-detail-store/provider' +import llmDefault from '../../../nodes/llm/default' import { BlockEnum } from '../../../types' import WorkflowChecklist from '../index' -let mockChecklistItems: ChecklistItem[] = [ - { - id: 'plugin-1', - type: BlockEnum.Tool, - title: 'Missing Plugin', - errorMessages: [], - canNavigate: false, - isPluginMissing: true, - }, - { - id: 'node-1', - type: BlockEnum.LLM, - title: 'Broken Node', - errorMessages: ['Needs configuration'], - canNavigate: true, - isPluginMissing: false, - }, -] - -const mockHandleNodeSelect = vi.fn() - -vi.mock('../../../hooks/use-checklist', () => ({ - useChecklist: () => mockChecklistItems, +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + postMarketplace: vi.fn().mockResolvedValue({ data: { list: [] } }), })) -vi.mock('../../../hooks/use-nodes-interactions', () => ({ - useNodesInteractions: () => ({ - handleNodeSelect: mockHandleNodeSelect, - }), -})) +function createChecklistQueryClient() { + const { queryClient } = createConsoleQueryWrapper({ appDslVersion: '0.6.0' }) + seedModelProviderQueries(queryClient) + seedToolProviders(queryClient, { + builtInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + }) + queryClient.setQueryData(consoleQuery.workspaces.current.agentProviders.get.queryKey(), []) + queryClient.setQueryData(consoleQuery.workspaces.current.triggers.get.queryKey({ input: {} }), []) + queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), []) + return queryClient +} -describe('WorkflowChecklist', () => { - beforeEach(() => { - vi.clearAllMocks() - mockChecklistItems = [ - { - id: 'plugin-1', - type: BlockEnum.Tool, - title: 'Missing Plugin', - errorMessages: [], - canNavigate: false, - isPluginMissing: true, +function createWarningGraph() { + const start = createStartNode({ + id: 'start', + width: 240, + height: 80, + data: { variables: [] }, + }) + const missingPlugin = createNode({ + id: 'missing-plugin', + width: 240, + height: 80, + data: { + type: BlockEnum.Tool, + title: 'Missing Plugin', + tool_parameters: {}, + provider_type: CollectionType.builtIn, + provider_id: 'missing-provider', + plugin_unique_identifier: 'plugin/tool@0.0.1', + }, + }) + const invalidNode = createNode({ + id: 'invalid-node', + width: 240, + height: 80, + data: { ...llmDefault.defaultValue, type: BlockEnum.LLM, title: 'Broken Node' }, + }) + return { + nodes: [start, missingPlugin, invalidNode], + edges: [ + createEdge({ source: start.id, target: missingPlugin.id }), + createEdge({ source: start.id, target: invalidNode.id }), + ], + } +} + +function createInlineAgentGraph() { + const queryClient = createChecklistQueryClient() + const nodeId = 'inline-agent' + queryClient.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { params: { app_id: 'app-1', node_id: nodeId } }, + }), + zWorkflowAgentComposerResponse.parse({ + agent_soul: { + config_files: [], + config_skills: [], + tools: { dify_tools: [] satisfies AgentSoulDifyToolConfig[] }, }, - { - id: 'node-1', - type: BlockEnum.LLM, - title: 'Broken Node', - errorMessages: ['Needs configuration'], - canNavigate: true, - isPluginMissing: false, - }, - ] + node_job: {}, + save_options: [], + soul_lock: { locked: false }, + variant: 'workflow', + }), + ) + const start = createStartNode({ + id: 'start', + width: 240, + height: 80, + data: { variables: [] }, }) - - it('should split checklist items into plugin and node groups and delegate clicks to node selection by default', async () => { - const user = userEvent.setup() - render(, { - resource: { kind: 'app', appId: 'app-1', mode: 'workflow' }, - }) - - expect(screen.getByText('2')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'workflow.panel.checklist 2' })) - expect(screen.getByText('Missing Plugin')).toHaveTextContent('Missing Plugin') - await user.click(screen.getByRole('button', { name: /Needs configuration/ })) - - expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1') - }) - - it('should use the custom item click handler when provided', async () => { - const user = userEvent.setup() - const onItemClick = vi.fn() - render(, { - resource: { kind: 'app', appId: 'app-1', mode: 'workflow' }, - }) - - await user.click(screen.getByRole('button', { name: 'workflow.panel.checklist 2' })) - await user.click(screen.getByRole('button', { name: /Needs configuration/ })) - - expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' })) - expect(mockHandleNodeSelect).not.toHaveBeenCalled() - }) - - it('should show the tooltip without replacing the warning count in the accessible name', async () => { - const user = userEvent.setup() - render(, { - resource: { kind: 'app', appId: 'app-1', mode: 'workflow' }, - }) - - const trigger = screen.getByRole('button', { name: 'workflow.panel.checklist 2' }) - await user.hover(trigger) - - expect(await screen.findByText('workflow.panel.checklist', { selector: 'div' })).toBeVisible() - expect(trigger).toHaveAccessibleName('workflow.panel.checklist 2') - await user.click(trigger) - expect(screen.getByText('Missing Plugin')).toBeInTheDocument() - }) - - it('should open the inline agent editor after selecting an inline agent reference warning', async () => { - const user = userEvent.setup() - mockChecklistItems[1] = { - ...mockChecklistItems[1]!, + const agent = createNode({ + id: nodeId, + width: 240, + height: 80, + data: { type: BlockEnum.AgentV2, title: 'Inline Agent', - openInlineAgentPanel: true, - } - const { store } = render(, { + agent_node_kind: 'dify_agent', + version: '2', + agent_binding: { + binding_type: 'inline_agent', + agent_id: 'agent-1', + current_snapshot_id: 'snapshot-1', + }, + }, + }) + return { + nodes: [start, agent], + edges: [createEdge({ source: start.id, target: agent.id })], + queryClient, + } +} + +function renderChecklist( + nodes: Node[], + edges: Edge[], + options: { + disabled?: boolean + onItemClick?: Parameters[0]['onItemClick'] + queryClient?: ReturnType + } = {}, +) { + return render( + + + + , + { resource: { kind: 'app', appId: 'app-1', mode: 'workflow' }, - }) + initialStoreState: { nodes }, + nodes, + edges, + queryClient: options.queryClient ?? createChecklistQueryClient(), + reactFlowProps: { fitView: false }, + }, + ) +} - await user.click(screen.getByRole('button', { name: 'workflow.panel.checklist 2' })) - await user.click(screen.getByRole('button', { name: /Needs configuration/ })) +function selectedCanvasNodeIds() { + return ( + screen.getByRole('status', { name: CANVAS_NODES_STATUS_NAME }).textContent?.split(',') ?? [] + ).filter((node) => node.endsWith('*')) +} - expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1') - expect(store.getState().openInlineAgentPanelNodeId).toBe('node-1') +describe('WorkflowChecklist', () => { + it('groups real missing-plugin and invalid-node warnings, then selects the invalid node', async () => { + const user = userEvent.setup() + const { nodes, edges } = createWarningGraph() + renderChecklist(nodes, edges) + + const trigger = await screen.findByRole('button', { name: 'workflow.panel.checklist 2' }) + expect(trigger).toHaveTextContent('2') + await user.click(trigger) + expect(screen.getByText(/pluginsNotInstalled/)).toBeInTheDocument() + expect(screen.getByText('Missing Plugin')).toBeInTheDocument() + expect(screen.getByText('Broken Node')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /workflow\.errorMsg\.fieldRequired/ })) + + await waitFor(() => expect(selectedCanvasNodeIds()).toEqual(['invalid-node*'])) + expect( + screen.queryByRole('button', { name: /workflow\.errorMsg\.fieldRequired/ }), + ).not.toBeInTheDocument() }) - it('should render the resolved state when there are no checklist warnings', async () => { + it('uses the caller item-click boundary without changing canvas selection', async () => { const user = userEvent.setup() - mockChecklistItems = [] + const onItemClick = vi.fn() + const { nodes, edges } = createWarningGraph() + renderChecklist(nodes, edges, { onItemClick }) - render(, { - resource: { kind: 'app', appId: 'app-1', mode: 'workflow' }, - }) + await user.click(await screen.findByRole('button', { name: 'workflow.panel.checklist 2' })) + await user.click(screen.getByRole('button', { name: /workflow\.errorMsg\.fieldRequired/ })) - await user.click(screen.getByRole('button', { name: 'workflow.panel.checklist' })) + expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'invalid-node' })) + expect(selectedCanvasNodeIds()).toEqual([]) + expect( + screen.queryByRole('button', { name: /workflow\.errorMsg\.fieldRequired/ }), + ).not.toBeInTheDocument() + }) + + it('selects an inline agent warning and opens its editor panel', async () => { + const user = userEvent.setup() + const { nodes, edges, queryClient } = createInlineAgentGraph() + const { store } = renderChecklist(nodes, edges, { queryClient }) + + await user.click(await screen.findByRole('button', { name: 'workflow.panel.checklist 1' })) + await user.click(await screen.findByRole('button', { name: /model/i })) + + await waitFor(() => expect(selectedCanvasNodeIds()).toEqual(['inline-agent*'])) + expect(store.getState().openInlineAgentPanelNodeId).toBe('inline-agent') + expect(screen.queryByRole('button', { name: /model/i })).not.toBeInTheDocument() + }) + + it('renders the resolved state for a valid graph', async () => { + const user = userEvent.setup() + const nodes = [ + createStartNode({ + id: 'start', + width: 240, + height: 80, + data: { variables: [] }, + }), + ] + renderChecklist(nodes, []) + + await user.click(await screen.findByRole('button', { name: 'workflow.panel.checklist' })) expect(screen.getByText(/checklistResolved/i)).toBeInTheDocument() }) - it('should ignore popover open changes when the checklist is disabled', () => { - render(, { - resource: { kind: 'app', appId: 'app-1', mode: 'workflow' }, - }) + it('does not open when disabled', async () => { + const { nodes, edges } = createWarningGraph() + renderChecklist(nodes, edges, { disabled: true }) - expect(screen.getByText('2').closest('button')).toBeDisabled() + expect(await screen.findByRole('button', { name: 'workflow.panel.checklist 2' })).toBeDisabled() + expect(screen.queryByText('Broken Node')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts index 7f8b2393a3b..3068448e43b 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts @@ -1,72 +1,156 @@ -// oxlint-disable-next-line no-restricted-imports -- The SSE boundary fixture uses the transport callback contract; it does not issue HTTP requests. -import type { IOtherOptions } from '@/service/base' -import { act } from '@testing-library/react' +import type { useWorkflowRun } from '../../../../hooks/use-workflow-run' +import type { Shape as WorkflowStoreShape } from '@/app/components/workflow/store/workflow' +import type { VarInInspect } from '@/types/workflow' +import { useQuery } from '@tanstack/react-query' +import { act, waitFor } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { createNode } from '@/app/components/workflow/__tests__/fixtures' import { baseRunningData, renderWorkflowFlowHook as renderHook, } from '@/app/components/workflow/__tests__/workflow-test-env' -import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { useSetWorkflowVarsWithValue as useFetchWorkflowInspectVars } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' +import { + BlockEnum, + NodeRunningStatus, + VarType, + WorkflowRunningStatus, +} from '@/app/components/workflow/types' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { seedToolProviders } from '@/test/console/tool-providers' import { useChat } from '../../hooks' -const mockHandleRun = vi.fn() -const mockNotify = vi.fn() -const mockFetchInspectVars = vi.fn() -const mockInvalidAllLastRun = vi.fn() -const mockSubmitHumanInputForm = vi.fn() -const mockSseGet = vi.fn() +type HandleRun = ReturnType['handleRun'] +type RunCallbacks = NonNullable[1]> -vi.mock('@/service/base', () => ({ - sseGet: (...args: unknown[]) => mockSseGet(...args), +const { fetchAllInspectVars, fetchLastRun } = vi.hoisted(() => ({ + fetchAllInspectVars: vi.fn(), + fetchLastRun: vi.fn(), })) +const mockHandleRun = vi.fn() -vi.mock('@/service/use-workflow', () => ({ - useInvalidateAllLastRun: () => mockInvalidAllLastRun, -})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.byAppId.workflows.draft.nodes.byNodeId.lastRun.get': (...args: unknown[]) => + fetchLastRun(...args), + }), + } +}) -vi.mock('@/service/workflow', () => ({ - submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputForm(...args), -})) - -vi.mock('@/app/notifications', () => ({ - toast: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - info: (...args: unknown[]) => mockNotify(...args), - }, -})) - -vi.mock('../../../../hooks/use-workflow-run', () => ({ - useWorkflowRun: () => ({ handleRun: mockHandleRun }), -})) - -vi.mock('../../../../hooks/use-set-workflow-vars-with-value', () => ({ - useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }), +vi.mock('@/service/workflow', async (importOriginal) => ({ + ...(await importOriginal()), + fetchAllInspectVars: (...args: unknown[]) => fetchAllInspectVars(...args), })) // Event fixtures omit unrelated protocol fields while preserving the real callback names. type EventFixture = T extends object ? { [K in keyof T]?: EventFixture } : T type SSECallbacks = { - [K in keyof IOtherOptions]-?: NonNullable extends (...args: infer A) => infer R + [K in keyof RunCallbacks]-?: NonNullable extends (...args: infer A) => infer R ? (...args: { [I in keyof A]: EventFixture }) => R : never } +const inspectNode = createNode({ + id: 'node-1', + data: { + type: BlockEnum.Code, + title: 'Code', + outputs: { answer: { type: VarType.string } }, + _runningStatus: NodeRunningStatus.Succeeded, + }, +}) + +const inspectVar: VarInInspect = { + id: 'var-1', + type: 'node', + name: 'answer', + description: 'Answer', + selector: ['node-1', 'answer'], + value_type: VarType.string, + value: 'completed', + edited: false, + visible: true, + is_truncated: false, + full_content: null, +} + +type FetchInspectVars = ReturnType['fetchInspectVars'] + +function useChatWithRealInspector( + config: Parameters[0], + registerFetchInspectVars: (fetchInspectVars: FetchInspectVars) => void, +) { + const { fetchInspectVars } = useFetchWorkflowInspectVars() + useQuery({ + ...consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.lastRun.get.queryOptions({ + input: { params: { app_id: 'app-1', node_id: 'node-1' } }, + }), + staleTime: Infinity, + }) + useLayoutEffect(() => { + registerFetchInspectVars(fetchInspectVars) + }, [fetchInspectVars, registerFetchInspectVars]) + return useChat(config) +} + describe('useChat – handleSend SSE callbacks', () => { let capturedCallbacks: SSECallbacks beforeEach(() => { - vi.clearAllMocks() mockHandleRun.mockReset() + fetchAllInspectVars.mockReset().mockResolvedValue([inspectVar]) + fetchLastRun.mockReset().mockResolvedValue({ id: 'last-run-2', status: 'succeeded' }) mockHandleRun.mockImplementation((_params: unknown, callbacks: unknown) => { capturedCallbacks = callbacks as SSECallbacks }) }) - function setupAndSend(config: Parameters[0] = {}) { - const hook = renderHook(() => useChat(config), { - resource: { kind: 'app', appId: 'app-1', mode: 'workflow' }, + function renderChat( + config: Parameters[0] = {}, + initialStoreState: Partial = {}, + ) { + let realFetchInspectVars: FetchInspectVars = async () => undefined + const fetchInspectVars: FetchInspectVars = (params) => realFetchInspectVars(params) + const registerFetchInspectVars = (nextFetchInspectVars: FetchInspectVars) => { + realFetchInspectVars = nextFetchInspectVars + } + const { queryClient } = createConsoleQueryWrapper() + seedToolProviders(queryClient, { + builtInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], }) + queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), []) + const lastRunKey = + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.lastRun.get.queryKey({ + input: { params: { app_id: 'app-1', node_id: 'node-1' } }, + }) + queryClient.setQueryData(lastRunKey, { id: 'last-run-1', status: 'succeeded' }) + + return { + ...renderHook(() => useChatWithRealInspector(config, registerFetchInspectVars), { + resource: { kind: 'app', appId: 'app-1', mode: 'workflow' }, + nodes: [inspectNode], + queryClient, + initialStoreState, + hooksStoreProps: { handleRun: mockHandleRun, fetchInspectVars }, + }), + lastRunKey, + queryClient, + } + } + + function setupAndSend( + config: Parameters[0] = {}, + initialStoreState: Parameters[1] = {}, + ) { + const hook = renderChat(config, initialStoreState) act(() => { hook.result.current.handleSend( { query: 'test' }, @@ -206,27 +290,46 @@ describe('useChat – handleSend SSE callbacks', () => { }) describe('onCompleted', () => { - it('should call fetchInspectVars and invalidAllLastRun when not paused', async () => { - const { result } = setupAndSend() + it('refreshes real inspector state and invalidates cached last-run data when not paused', async () => { + const { lastRunKey, queryClient, result, store } = setupAndSend() expect(result.current.isResponding).toBe(true) await act(async () => { await capturedCallbacks.onCompleted(false) }) expect(result.current.isResponding).toBe(false) - expect(mockFetchInspectVars).toHaveBeenCalledWith({}) - expect(mockInvalidAllLastRun).toHaveBeenCalled() + await waitFor(() => + expect(store.getState().nodesWithInspectVars).toEqual([ + expect.objectContaining({ + nodeId: 'node-1', + vars: [expect.objectContaining({ id: 'var-1', value: 'completed' })], + }), + ]), + ) + await waitFor(() => + expect(queryClient.getQueryData(lastRunKey)).toEqual({ + id: 'last-run-2', + status: 'succeeded', + }), + ) }) - it('should not call fetchInspectVars when workflow is paused', async () => { - const { store, result } = setupAndSend() - store.setState({ - workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Paused } }), - }) + it('leaves inspector and last-run data unchanged when workflow is paused', async () => { + const { lastRunKey, queryClient, result, store } = setupAndSend( + {}, + { + workflowRunningData: baseRunningData({ + result: { status: WorkflowRunningStatus.Paused }, + }), + }, + ) await act(async () => { await capturedCallbacks.onCompleted(false) }) - expect(mockFetchInspectVars).not.toHaveBeenCalled() - expect(mockInvalidAllLastRun).not.toHaveBeenCalled() + expect(store.getState().nodesWithInspectVars).toEqual([]) + expect(queryClient.getQueryData(lastRunKey)).toEqual({ + id: 'last-run-1', + status: 'succeeded', + }) expect(result.current.isResponding).toBe(false) }) @@ -277,10 +380,7 @@ describe('useChat – handleSend SSE callbacks', () => { onCancel(cancelSuggested) return Promise.resolve({ data: ['suggestion1'] }) }) - const hook = renderHook( - () => useChat({ suggested_questions_after_answer: { enabled: true } }), - { resource: { kind: 'app', appId: 'app-1', mode: 'workflow' } }, - ) + const hook = renderChat({ suggested_questions_after_answer: { enabled: true } }) mockHandleRun.mockImplementation((_params: unknown, callbacks: unknown) => { capturedCallbacks = callbacks as SSECallbacks @@ -306,10 +406,7 @@ describe('useChat – handleSend SSE callbacks', () => { it('should set suggestedQuestions to empty array when fetch fails', async () => { const mockGetSuggested = vi.fn().mockRejectedValue(new Error('fail')) - const hook = renderHook( - () => useChat({ suggested_questions_after_answer: { enabled: true } }), - { resource: { kind: 'app', appId: 'app-1', mode: 'workflow' } }, - ) + const hook = renderChat({ suggested_questions_after_answer: { enabled: true } }) mockHandleRun.mockImplementation((_params: unknown, callbacks: unknown) => { capturedCallbacks = callbacks as SSECallbacks