mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): exercise real workflow owners in existing specs (#44741)
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
+155
-58
@@ -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
|
||||
|
||||
Reference in new issue
Block a user