test(web): exercise real workflow owners in existing specs (#44741)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-25 09:21:40 +00:00
1 parent 5c510d0737
commit 549af97a4a
5 files changed
+834 -472

No files matched your search

@@ -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<typeof draftResponse>()
fetchDraft.mockReturnValue(deferred.promise)
let isCurrent = true
const { result, store } = setup()
let refresh!: Promise<boolean>
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<typeof draftResponse>()
const second = createDeferred<typeof draftResponse>()
fetchDraft.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
let firstIsCurrent = true
const { result, store } = setup()
let firstRefresh!: Promise<boolean>
let secondRefresh!: Promise<boolean>
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<typeof draftResponse>()
fetchDraft.mockReturnValue(deferred.promise)
const { result, store } = setup()
let refresh!: Promise<boolean>
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()
})
})
@@ -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<FetchWorkflowDraftResponse>>(),
}))
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/app/app-1/workflow',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
vi.mock('@/service/workflow', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/workflow')>()),
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<ReturnType<typeof useWorkflowRefreshDraft>>()
const flow = createRef<ReactFlowInstance>()
function RefreshProbe() {
const hook = useWorkflowRefreshDraft()
const canvas = useReactFlow()
useImperativeHandle(refresh, () => hook, [hook])
useImperativeHandle(flow, () => canvas, [canvas])
return null
}
const rendered = renderWorkflowComponent(
<NuqsTestingAdapter>
<EventEmitterContextProvider>
<WorkflowWithDefaultContext nodes={localNodes} edges={localEdges}>
<Workflow nodes={localNodes} edges={localEdges} viewport={localViewport}>
<RefreshProbe />
</Workflow>
</WorkflowWithDefaultContext>
</EventEmitterContextProvider>
</NuqsTestingAdapter>,
{
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<ReactFlowInstance | null>,
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<FetchWorkflowDraftResponse>()
fetchDraft.mockReturnValue(deferred.promise)
let isCurrent = true
const { flow, refresh, store } = setup()
let request!: Promise<boolean>
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<FetchWorkflowDraftResponse>()
const second = createDeferred<FetchWorkflowDraftResponse>()
fetchDraft.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
let firstIsCurrent = true
const { flow, refresh, store } = setup()
let firstRequest!: Promise<boolean>
let secondRequest!: Promise<boolean>
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<FetchWorkflowDraftResponse['conversation_variables']>
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<FetchWorkflowDraftResponse>()
fetchDraft.mockReturnValue(deferred.promise)
const { flow, refresh, store } = setup()
let request!: Promise<boolean>
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)
})
})
@@ -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 (
<>
<ContextMenu open={open}>
<NodeContextmenu
onClose={() => {
setOpen(false)
onClose()
}}
/>
</ContextMenu>
<CanvasNodesCatalogProbe />
</>
)
}
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(<ControlledNodeMenu onClose={onClose} />, {
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(
<ReactFlowProvider>
<ContextMenu open>
<NodeContextmenu onClose={mockClose} />
</ContextMenu>
</ReactFlowProvider>,
{ 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()
})
})
@@ -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<typeof import('@/service/base')>()),
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(<WorkflowChecklist disabled={false} />, {
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(<WorkflowChecklist disabled={false} onItemClick={onItemClick} />, {
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(<WorkflowChecklist disabled={false} />, {
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(<WorkflowChecklist disabled={false} />, {
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<typeof WorkflowChecklist>[0]['onItemClick']
queryClient?: ReturnType<typeof createChecklistQueryClient>
} = {},
) {
return render(
<DatasetsDetailProvider nodes={nodes}>
<WorkflowChecklist disabled={options.disabled ?? false} onItemClick={options.onItemClick} />
<CanvasNodesCatalogProbe />
</DatasetsDetailProvider>,
{
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(<WorkflowChecklist disabled={false} />, {
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(<WorkflowChecklist disabled={true} />, {
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()
})
})
@@ -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<typeof useWorkflowRun>['handleRun']
type RunCallbacks = NonNullable<Parameters<HandleRun>[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<HandleRun>()
vi.mock('@/service/use-workflow', () => ({
useInvalidateAllLastRun: () => mockInvalidAllLastRun,
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
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<typeof import('@/service/workflow')>()),
fetchAllInspectVars: (...args: unknown[]) => fetchAllInspectVars(...args),
}))
// Event fixtures omit unrelated protocol fields while preserving the real callback names.
type EventFixture<T> = T extends object ? { [K in keyof T]?: EventFixture<T[K]> } : T
type SSECallbacks = {
[K in keyof IOtherOptions]-?: NonNullable<IOtherOptions[K]> extends (...args: infer A) => infer R
[K in keyof RunCallbacks]-?: NonNullable<RunCallbacks[K]> extends (...args: infer A) => infer R
? (...args: { [I in keyof A]: EventFixture<A[I]> }) => 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<typeof useFetchWorkflowInspectVars>['fetchInspectVars']
function useChatWithRealInspector(
config: Parameters<typeof useChat>[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<typeof useChat>[0] = {}) {
const hook = renderHook(() => useChat(config), {
resource: { kind: 'app', appId: 'app-1', mode: 'workflow' },
function renderChat(
config: Parameters<typeof useChat>[0] = {},
initialStoreState: Partial<WorkflowStoreShape> = {},
) {
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<typeof useChat>[0] = {},
initialStoreState: Parameters<typeof renderChat>[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