mirror of
https://github.com/langgenius/dify.git
synced 2026-03-19 21:57:33 +08:00
test(workflow): add unit tests for workflow components (#33741)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
410
web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx
Normal file
410
web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx
Normal file
@ -0,0 +1,410 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { useEdges, useNodes, useStoreApi } from 'reactflow'
|
||||
import { createEdge, createNode } from '../__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '../__tests__/workflow-test-env'
|
||||
import EdgeContextmenu from '../edge-contextmenu'
|
||||
import { useEdgesInteractions } from '../hooks/use-edges-interactions'
|
||||
|
||||
const mockSaveStateToHistory = vi.fn()
|
||||
|
||||
vi.mock('../hooks/use-workflow-history', () => ({
|
||||
useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }),
|
||||
WorkflowHistoryEvent: {
|
||||
EdgeDelete: 'EdgeDelete',
|
||||
EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
|
||||
EdgeSourceHandleChange: 'EdgeSourceHandleChange',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: () => false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const { useEdgesInteractions } = await import('../hooks/use-edges-interactions')
|
||||
const { usePanelInteractions } = await import('../hooks/use-panel-interactions')
|
||||
|
||||
return {
|
||||
useEdgesInteractions,
|
||||
usePanelInteractions,
|
||||
}
|
||||
})
|
||||
|
||||
type EdgeRuntimeState = {
|
||||
_hovering?: boolean
|
||||
_isBundled?: boolean
|
||||
}
|
||||
|
||||
type NodeRuntimeState = {
|
||||
selected?: boolean
|
||||
_isBundled?: boolean
|
||||
}
|
||||
|
||||
const getEdgeRuntimeState = (edge?: Edge): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
const getNodeRuntimeState = (node?: Node): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
function createFlowNodes() {
|
||||
return [
|
||||
createNode({ id: 'n1' }),
|
||||
createNode({ id: 'n2', position: { x: 100, y: 0 } }),
|
||||
]
|
||||
}
|
||||
|
||||
function createFlowEdges() {
|
||||
return [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-a',
|
||||
data: { _hovering: false },
|
||||
selected: true,
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-b',
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
let latestNodes: Node[] = []
|
||||
let latestEdges: Edge[] = []
|
||||
|
||||
const RuntimeProbe = () => {
|
||||
latestNodes = useNodes() as Node[]
|
||||
latestEdges = useEdges() as Edge[]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const hooksStoreProps = {
|
||||
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const EdgeMenuHarness = () => {
|
||||
const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions()
|
||||
const edges = useEdges() as Edge[]
|
||||
const reactFlowStore = useStoreApi()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace')
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
handleEdgeDelete()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleEdgeDelete])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RuntimeProbe />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Right-click edge e1"
|
||||
onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e1') as never)}
|
||||
>
|
||||
edge-e1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Right-click edge e2"
|
||||
onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e2') as never)}
|
||||
>
|
||||
edge-e2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove edge e1"
|
||||
onClick={() => {
|
||||
const { edges, setEdges } = reactFlowStore.getState()
|
||||
setEdges(edges.filter(edge => edge.id !== 'e1'))
|
||||
}}
|
||||
>
|
||||
remove-e1
|
||||
</button>
|
||||
<EdgeContextmenu />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderEdgeMenu(options?: {
|
||||
nodes?: Node[]
|
||||
edges?: Edge[]
|
||||
initialStoreState?: Record<string, unknown>
|
||||
}) {
|
||||
const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {}
|
||||
|
||||
return renderWorkflowFlowComponent(<EdgeMenuHarness />, {
|
||||
nodes,
|
||||
edges,
|
||||
initialStoreState,
|
||||
hooksStoreProps,
|
||||
reactFlowProps: { fitView: false },
|
||||
})
|
||||
}
|
||||
|
||||
describe('EdgeContextmenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestNodes = []
|
||||
latestEdges = []
|
||||
})
|
||||
|
||||
it('should not render when edgeMenu is absent', () => {
|
||||
renderWorkflowFlowComponent(<EdgeContextmenu />, {
|
||||
nodes: createFlowNodes(),
|
||||
edges: createFlowEdges(),
|
||||
hooksStoreProps,
|
||||
reactFlowProps: { fitView: false },
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should delete the menu edge and close the menu when another edge is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderEdgeMenu({
|
||||
edges: [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-a',
|
||||
selected: true,
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-b',
|
||||
selected: false,
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'e2',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
|
||||
expect(screen.getByText(/^del$/i)).toBeInTheDocument()
|
||||
|
||||
await user.click(deleteAction)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latestEdges).toHaveLength(1)
|
||||
expect(latestEdges[0].id).toBe('e1')
|
||||
expect(latestEdges[0].selected).toBe(true)
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
})
|
||||
|
||||
it('should not render the menu when the referenced edge no longer exists', () => {
|
||||
renderWorkflowFlowComponent(<EdgeContextmenu />, {
|
||||
nodes: createFlowNodes(),
|
||||
edges: createFlowEdges(),
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'missing-edge',
|
||||
},
|
||||
},
|
||||
hooksStoreProps,
|
||||
reactFlowProps: { fitView: false },
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the edge menu at the right-click position', async () => {
|
||||
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
|
||||
|
||||
renderEdgeMenu()
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument()
|
||||
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
x: 320,
|
||||
y: 180,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should delete the right-clicked edge and close the menu when delete is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderEdgeMenu()
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
})
|
||||
|
||||
await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(latestEdges.map(edge => edge.id)).toEqual(['e1'])
|
||||
})
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Delete', 'Delete'],
|
||||
['Backspace', 'Backspace'],
|
||||
])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => {
|
||||
renderEdgeMenu({
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
selected: true,
|
||||
data: { selected: true, _isBundled: true },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 100, y: 0 },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 240,
|
||||
clientY: 120,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document.body, { key })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(latestEdges.map(edge => edge.id)).toEqual(['e1'])
|
||||
expect(latestNodes.map(node => node.id)).toEqual(['n1', 'n2'])
|
||||
expect(latestNodes.every(node => !node.selected && !getNodeRuntimeState(node).selected)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => {
|
||||
renderEdgeMenu({
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
selected: true,
|
||||
data: { selected: true, _isBundled: true },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 100, y: 0 },
|
||||
selected: true,
|
||||
data: { selected: true, _isBundled: true },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
|
||||
clientX: 200,
|
||||
clientY: 100,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Delete' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(latestEdges.map(edge => edge.id)).toEqual(['e2'])
|
||||
expect(latestNodes).toHaveLength(2)
|
||||
expect(latestNodes.every(node =>
|
||||
!node.selected
|
||||
&& !getNodeRuntimeState(node).selected
|
||||
&& !getNodeRuntimeState(node)._isBundled,
|
||||
)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should retarget the menu and selected edge when right-clicking a different edge', async () => {
|
||||
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
|
||||
|
||||
renderEdgeMenu()
|
||||
const edgeOneButton = screen.getByLabelText('Right-click edge e1')
|
||||
const edgeTwoButton = screen.getByLabelText('Right-click edge e2')
|
||||
|
||||
fireEvent.contextMenu(edgeOneButton, {
|
||||
clientX: 80,
|
||||
clientY: 60,
|
||||
})
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.contextMenu(edgeTwoButton, {
|
||||
clientX: 360,
|
||||
clientY: 240,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('menu')).toHaveLength(1)
|
||||
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
x: 360,
|
||||
y: 240,
|
||||
}))
|
||||
expect(latestEdges.find(edge => edge.id === 'e1')?.selected).toBe(false)
|
||||
expect(latestEdges.find(edge => edge.id === 'e2')?.selected).toBe(true)
|
||||
expect(latestEdges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide the menu when the target edge disappears after opening it', async () => {
|
||||
const { container } = renderEdgeMenu()
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
|
||||
clientX: 160,
|
||||
clientY: 100,
|
||||
})
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(container.querySelector('button[aria-label="Remove edge e1"]') as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,11 +2,11 @@ import type { InputVar } from '../types'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow'
|
||||
import { useNodes } from 'reactflow'
|
||||
import Features from '../features'
|
||||
import { InputVarType } from '../types'
|
||||
import { createStartNode } from './fixtures'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
import { renderWorkflowFlowComponent } from './workflow-test-env'
|
||||
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockHandleAddVariable = vi.fn()
|
||||
@ -112,17 +112,15 @@ const DelayedFeatures = () => {
|
||||
return <Features />
|
||||
}
|
||||
|
||||
const renderFeatures = (options?: Parameters<typeof renderWorkflowComponent>[1]) => {
|
||||
return renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={[startNode]} edges={[]} fitView />
|
||||
<DelayedFeatures />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
options,
|
||||
const renderFeatures = (options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<DelayedFeatures />,
|
||||
{
|
||||
nodes: [startNode],
|
||||
edges: [],
|
||||
...options,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('Features', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@ -42,6 +42,13 @@ export function createStartNode(overrides: Omit<Partial<Node>, 'data'> & { data?
|
||||
})
|
||||
}
|
||||
|
||||
export function createNodeDataFactory<T extends CommonNodeType & Record<string, unknown>>(defaults: T) {
|
||||
return (overrides: Partial<T> = {}): T => ({
|
||||
...defaults,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
export function createTriggerNode(
|
||||
triggerType: BlockEnum.TriggerSchedule | BlockEnum.TriggerWebhook | BlockEnum.TriggerPlugin = BlockEnum.TriggerWebhook,
|
||||
overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {},
|
||||
|
||||
9
web/app/components/workflow/__tests__/i18n.ts
Normal file
9
web/app/components/workflow/__tests__/i18n.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
export function resolveDocLink(path: string, baseUrl = 'https://docs.example.com') {
|
||||
return `${baseUrl}${path}`
|
||||
}
|
||||
|
||||
export function createDocLinkMock(baseUrl = 'https://docs.example.com') {
|
||||
return vi.fn((path: string) => resolveDocLink(path, baseUrl))
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
createCredentialState,
|
||||
createDefaultModel,
|
||||
createModel,
|
||||
createModelItem,
|
||||
createProviderMeta,
|
||||
} from './model-provider-fixtures'
|
||||
|
||||
describe('model-provider-fixtures', () => {
|
||||
describe('createModelItem', () => {
|
||||
it('should return the default text embedding model item', () => {
|
||||
expect(createModelItem()).toEqual({
|
||||
model: 'text-embedding-3-large',
|
||||
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow overriding the default model item fields', () => {
|
||||
expect(createModelItem({
|
||||
model: 'bge-large',
|
||||
status: ModelStatusEnum.disabled,
|
||||
load_balancing_enabled: true,
|
||||
})).toEqual(expect.objectContaining({
|
||||
model: 'bge-large',
|
||||
status: ModelStatusEnum.disabled,
|
||||
load_balancing_enabled: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createModel', () => {
|
||||
it('should build an active provider model with one default model item', () => {
|
||||
const result = createModel()
|
||||
|
||||
expect(result.provider).toBe('openai')
|
||||
expect(result.status).toBe(ModelStatusEnum.active)
|
||||
expect(result.models).toHaveLength(1)
|
||||
expect(result.models[0]).toEqual(createModelItem())
|
||||
})
|
||||
|
||||
it('should use override values for provider metadata and model list', () => {
|
||||
const customModelItem = createModelItem({
|
||||
model: 'rerank-v1',
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
})
|
||||
|
||||
expect(createModel({
|
||||
provider: 'cohere',
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [customModelItem],
|
||||
})).toEqual(expect.objectContaining({
|
||||
provider: 'cohere',
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [customModelItem],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDefaultModel', () => {
|
||||
it('should return the default provider and model selection', () => {
|
||||
expect(createDefaultModel()).toEqual({
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-3-large',
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow overriding the default provider selection', () => {
|
||||
expect(createDefaultModel({
|
||||
provider: 'azure_openai',
|
||||
model: 'text-embedding-3-small',
|
||||
})).toEqual({
|
||||
provider: 'azure_openai',
|
||||
model: 'text-embedding-3-small',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProviderMeta', () => {
|
||||
it('should return provider metadata with credential and system configuration defaults', () => {
|
||||
expect(createProviderMeta()).toEqual({
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
help: {
|
||||
title: { en_US: 'Help', zh_Hans: 'Help' },
|
||||
url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' },
|
||||
},
|
||||
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
|
||||
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
|
||||
supported_model_types: [ModelTypeEnum.textEmbedding],
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
provider_credential_schema: {
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: { en_US: 'Model', zh_Hans: 'Model' },
|
||||
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
|
||||
},
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply provider metadata overrides', () => {
|
||||
expect(createProviderMeta({
|
||||
provider: 'bedrock',
|
||||
supported_model_types: [ModelTypeEnum.textGeneration],
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
|
||||
quota_configurations: [],
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
provider: 'bedrock',
|
||||
supported_model_types: [ModelTypeEnum.textGeneration],
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
|
||||
quota_configurations: [],
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCredentialState', () => {
|
||||
it('should return the default active credential panel state', () => {
|
||||
expect(createCredentialState()).toEqual({
|
||||
variant: 'api-active',
|
||||
priority: 'apiKeyOnly',
|
||||
supportsCredits: false,
|
||||
showPrioritySwitcher: false,
|
||||
isCreditsExhausted: false,
|
||||
hasCredentials: true,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow overriding the credential panel state', () => {
|
||||
expect(createCredentialState({
|
||||
variant: 'credits-active',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
credits: 12,
|
||||
credentialName: 'Primary Key',
|
||||
})).toEqual(expect.objectContaining({
|
||||
variant: 'credits-active',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
credits: 12,
|
||||
credentialName: 'Primary Key',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
import type {
|
||||
DefaultModel,
|
||||
Model,
|
||||
ModelItem,
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export function createModelItem(overrides: Partial<ModelItem> = {}): ModelItem {
|
||||
return {
|
||||
model: 'text-embedding-3-large',
|
||||
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createModel(overrides: Partial<Model> = {}): Model {
|
||||
return {
|
||||
provider: 'openai',
|
||||
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
|
||||
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [createModelItem()],
|
||||
status: ModelStatusEnum.active,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultModel(overrides: Partial<DefaultModel> = {}): DefaultModel {
|
||||
return {
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-3-large',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createProviderMeta(overrides: Partial<ModelProvider> = {}): ModelProvider {
|
||||
return {
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
help: {
|
||||
title: { en_US: 'Help', zh_Hans: 'Help' },
|
||||
url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' },
|
||||
},
|
||||
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
|
||||
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
|
||||
supported_model_types: [ModelTypeEnum.textEmbedding],
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
provider_credential_schema: {
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: { en_US: 'Model', zh_Hans: 'Model' },
|
||||
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
|
||||
},
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [],
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createCredentialState(overrides: Partial<CredentialPanelState> = {}): CredentialPanelState {
|
||||
return {
|
||||
variant: 'api-active',
|
||||
priority: 'apiKeyOnly',
|
||||
supportsCredits: false,
|
||||
showPrioritySwitcher: false,
|
||||
isCreditsExhausted: false,
|
||||
hasCredentials: true,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,12 @@
|
||||
import type { EdgeChange, ReactFlowProps } from 'reactflow'
|
||||
import type { Edge, Node } from '../types'
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { BaseEdge, internalsSymbol, Position, ReactFlowProvider, useStoreApi } from 'reactflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { WORKFLOW_DATA_UPDATE } from '../constants'
|
||||
import { Workflow } from '../index'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
const reactFlowState = vi.hoisted(() => ({
|
||||
lastProps: null as ReactFlowProps | null,
|
||||
}))
|
||||
|
||||
type WorkflowUpdateEvent = {
|
||||
type: string
|
||||
payload: {
|
||||
@ -23,6 +19,10 @@ const eventEmitterState = vi.hoisted(() => ({
|
||||
subscription: null as null | ((payload: WorkflowUpdateEvent) => void),
|
||||
}))
|
||||
|
||||
const reactFlowBridge = vi.hoisted(() => ({
|
||||
store: null as null | ReturnType<typeof useStoreApi>,
|
||||
}))
|
||||
|
||||
const workflowHookMocks = vi.hoisted(() => ({
|
||||
handleNodeDragStart: vi.fn(),
|
||||
handleNodeDrag: vi.fn(),
|
||||
@ -52,90 +52,64 @@ const workflowHookMocks = vi.hoisted(() => ({
|
||||
useWorkflowSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
function createInitializedNode(id: string, x: number, label: string) {
|
||||
return {
|
||||
id,
|
||||
position: { x, y: 0 },
|
||||
positionAbsolute: { x, y: 0 },
|
||||
width: 160,
|
||||
height: 40,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
data: { label },
|
||||
[internalsSymbol]: {
|
||||
positionAbsolute: { x, y: 0 },
|
||||
handleBounds: {
|
||||
source: [{
|
||||
id: null,
|
||||
nodeId: id,
|
||||
type: 'source',
|
||||
position: Position.Right,
|
||||
x: 160,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 40,
|
||||
}],
|
||||
target: [{
|
||||
id: null,
|
||||
nodeId: id,
|
||||
type: 'target',
|
||||
position: Position.Left,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 40,
|
||||
}],
|
||||
},
|
||||
z: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const baseNodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
},
|
||||
createInitializedNode('node-1', 0, 'Workflow node node-1'),
|
||||
createInitializedNode('node-2', 240, 'Workflow node node-2'),
|
||||
] as unknown as Node[]
|
||||
|
||||
const baseEdges = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
type: 'custom',
|
||||
source: 'node-1',
|
||||
target: 'node-2',
|
||||
data: { sourceType: 'start', targetType: 'end' },
|
||||
},
|
||||
] as unknown as Edge[]
|
||||
|
||||
const edgeChanges: EdgeChange[] = [{ id: 'edge-1', type: 'remove' }]
|
||||
|
||||
function createMouseEvent() {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
} as unknown as React.MouseEvent<Element, MouseEvent>
|
||||
}
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => () => null,
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const mod = await import('./reactflow-mock-state')
|
||||
const base = mod.createReactFlowModuleMock()
|
||||
const ReactFlowMock = (props: ReactFlowProps) => {
|
||||
reactFlowState.lastProps = props
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'reactflow-mock' },
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit edge mouse enter',
|
||||
'onClick': () => props.onEdgeMouseEnter?.(createMouseEvent(), baseEdges[0]),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit edge mouse leave',
|
||||
'onClick': () => props.onEdgeMouseLeave?.(createMouseEvent(), baseEdges[0]),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit edges change',
|
||||
'onClick': () => props.onEdgesChange?.(edgeChanges),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit edge context menu',
|
||||
'onClick': () => props.onEdgeContextMenu?.(createMouseEvent(), baseEdges[0]),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit node context menu',
|
||||
'onClick': () => props.onNodeContextMenu?.(createMouseEvent(), baseNodes[0]),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit pane context menu',
|
||||
'onClick': () => props.onPaneContextMenu?.(createMouseEvent()),
|
||||
}),
|
||||
props.children,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
SelectionMode: {
|
||||
Partial: 'partial',
|
||||
},
|
||||
ReactFlow: ReactFlowMock,
|
||||
default: ReactFlowMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
@ -166,7 +140,10 @@ vi.mock('../custom-connection-line', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../custom-edge', () => ({
|
||||
default: () => null,
|
||||
default: () => React.createElement(BaseEdge, {
|
||||
id: 'edge-1',
|
||||
path: 'M 0 0 L 100 0',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../help-line', () => ({
|
||||
@ -182,7 +159,7 @@ vi.mock('../node-contextmenu', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../nodes', () => ({
|
||||
default: () => null,
|
||||
default: ({ id }: { id: string }) => React.createElement('div', { 'data-testid': `workflow-node-${id}` }, `Workflow node ${id}`),
|
||||
}))
|
||||
|
||||
vi.mock('../nodes/data-source-empty', () => ({
|
||||
@ -289,17 +266,24 @@ vi.mock('../nodes/_base/components/variable/use-match-schema-type', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../workflow-history-store', () => ({
|
||||
WorkflowHistoryProvider: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
}))
|
||||
function renderSubject(options?: {
|
||||
nodes?: Node[]
|
||||
edges?: Edge[]
|
||||
initialStoreState?: Record<string, unknown>
|
||||
}) {
|
||||
const { nodes = baseNodes, edges = baseEdges, initialStoreState } = options ?? {}
|
||||
|
||||
function renderSubject() {
|
||||
return renderWorkflowComponent(
|
||||
<Workflow
|
||||
nodes={baseNodes}
|
||||
edges={baseEdges}
|
||||
/>,
|
||||
<ReactFlowProvider>
|
||||
<Workflow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<ReactFlowEdgeBootstrap nodes={nodes} edges={edges} />
|
||||
</Workflow>
|
||||
</ReactFlowProvider>,
|
||||
{
|
||||
initialStoreState,
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
@ -311,75 +295,106 @@ function renderSubject() {
|
||||
)
|
||||
}
|
||||
|
||||
function ReactFlowEdgeBootstrap({ nodes, edges }: { nodes: Node[], edges: Edge[] }) {
|
||||
const store = useStoreApi()
|
||||
|
||||
React.useEffect(() => {
|
||||
store.setState({
|
||||
edges,
|
||||
width: 500,
|
||||
height: 500,
|
||||
nodeInternals: new Map(nodes.map(node => [node.id, node])),
|
||||
})
|
||||
reactFlowBridge.store = store
|
||||
|
||||
return () => {
|
||||
reactFlowBridge.store = null
|
||||
}
|
||||
}, [edges, nodes, store])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getPane(container: HTMLElement) {
|
||||
const pane = container.querySelector('.react-flow__pane') as HTMLElement | null
|
||||
|
||||
if (!pane)
|
||||
throw new Error('Expected a rendered React Flow pane')
|
||||
|
||||
return pane
|
||||
}
|
||||
|
||||
describe('Workflow edge event wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reactFlowState.lastProps = null
|
||||
eventEmitterState.subscription = null
|
||||
reactFlowBridge.store = null
|
||||
})
|
||||
|
||||
it('should forward React Flow edge events to workflow handlers when emitted by the canvas', () => {
|
||||
renderSubject()
|
||||
it('should forward pane, node and edge-change events to workflow handlers when emitted by the canvas', async () => {
|
||||
const { container } = renderSubject()
|
||||
const pane = getPane(container)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse enter' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse leave' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit edges change' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit edge context menu' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit node context menu' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit pane context menu' }))
|
||||
act(() => {
|
||||
fireEvent.contextMenu(screen.getByText('Workflow node node-1'), { clientX: 24, clientY: 48 })
|
||||
fireEvent.contextMenu(pane, { clientX: 24, clientY: 48 })
|
||||
})
|
||||
|
||||
expect(workflowHookMocks.handleEdgeEnter).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), baseEdges[0])
|
||||
expect(workflowHookMocks.handleEdgeLeave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), baseEdges[0])
|
||||
expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(edgeChanges)
|
||||
expect(workflowHookMocks.handleEdgeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), baseEdges[0])
|
||||
expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), baseNodes[0])
|
||||
expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}))
|
||||
await waitFor(() => {
|
||||
expect(reactFlowBridge.store?.getState().onEdgesChange).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
reactFlowBridge.store?.getState().onEdgesChange?.([{ id: 'edge-1', type: 'select', selected: true }])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'edge-1', type: 'select' }),
|
||||
]))
|
||||
expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), expect.objectContaining({ id: 'node-1' }))
|
||||
expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', () => {
|
||||
renderSubject()
|
||||
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', async () => {
|
||||
renderSubject({
|
||||
edges: [
|
||||
{
|
||||
...baseEdges[0],
|
||||
selected: true,
|
||||
} as Edge,
|
||||
],
|
||||
})
|
||||
|
||||
expect(reactFlowState.lastProps?.deleteKeyCode).toBeNull()
|
||||
act(() => {
|
||||
fireEvent.keyDown(document.body, { key: 'Delete' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Workflow node node-1')).toBeInTheDocument()
|
||||
})
|
||||
expect(workflowHookMocks.handleEdgesChange).not.toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'edge-1', type: 'remove' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('should clear edgeMenu when workflow data updates remove the current edge', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<Workflow
|
||||
nodes={baseNodes}
|
||||
edges={baseEdges}
|
||||
/>,
|
||||
{
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'edge-1',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {},
|
||||
},
|
||||
const { store } = renderSubject({
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'edge-1',
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
eventEmitterState.subscription?.({
|
||||
|
||||
@ -4,10 +4,17 @@
|
||||
import type { Shape } from '../store/workflow'
|
||||
import { act, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useHooksStore } from '../hooks-store/store'
|
||||
import { useStore, useWorkflowStore } from '../store/workflow'
|
||||
import { renderNodeComponent, renderWorkflowComponent } from './workflow-test-env'
|
||||
import { createNode } from './fixtures'
|
||||
import {
|
||||
renderNodeComponent,
|
||||
renderWorkflowComponent,
|
||||
renderWorkflowFlowComponent,
|
||||
renderWorkflowFlowHook,
|
||||
} from './workflow-test-env'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test components that read from workflow contexts
|
||||
@ -43,6 +50,12 @@ function NodeRenderer(props: { id: string, data: { title: string }, selected?: b
|
||||
)
|
||||
}
|
||||
|
||||
function FlowReader() {
|
||||
const nodes = useNodes()
|
||||
const showConfirm = useStore(s => s.showConfirm)
|
||||
return React.createElement('div', { 'data-testid': 'flow-reader' }, `${nodes.length}:${showConfirm ? 'confirm' : 'clear'}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -134,3 +147,30 @@ describe('renderNodeComponent', () => {
|
||||
expect(screen.getByTestId('node-store')).toHaveTextContent('test-node-1:hand')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderWorkflowFlowComponent', () => {
|
||||
it('should provide both ReactFlow and Workflow contexts', () => {
|
||||
renderWorkflowFlowComponent(React.createElement(FlowReader), {
|
||||
nodes: [
|
||||
createNode({ id: 'n-1' }),
|
||||
createNode({ id: 'n-2' }),
|
||||
],
|
||||
initialStoreState: { showConfirm: { title: 'Hey', onConfirm: () => {} } },
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('flow-reader')).toHaveTextContent('2:confirm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderWorkflowFlowHook', () => {
|
||||
it('should render hooks inside a real ReactFlow provider', () => {
|
||||
const { result } = renderWorkflowFlowHook(() => useNodes(), {
|
||||
nodes: [
|
||||
createNode({ id: 'flow-1' }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].id).toBe('flow-1')
|
||||
})
|
||||
})
|
||||
|
||||
@ -69,6 +69,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import * as React from 'react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { temporal } from 'zundo'
|
||||
import { create } from 'zustand'
|
||||
import { WorkflowContext } from '../context'
|
||||
@ -252,6 +253,104 @@ export function renderWorkflowComponent(
|
||||
return { ...renderResult, ...stores }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderWorkflowFlowComponent / renderWorkflowFlowHook — real ReactFlow wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WorkflowFlowOptions = WorkflowProviderOptions & {
|
||||
nodes?: Node[]
|
||||
edges?: Edge[]
|
||||
reactFlowProps?: Omit<React.ComponentProps<typeof ReactFlow>, 'children' | 'nodes' | 'edges'>
|
||||
canvasStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
type WorkflowFlowComponentTestOptions = Omit<RenderOptions, 'wrapper'> & WorkflowFlowOptions
|
||||
type WorkflowFlowHookTestOptions<P> = Omit<RenderHookOptions<P>, 'wrapper'> & WorkflowFlowOptions
|
||||
|
||||
function createWorkflowFlowWrapper(
|
||||
stores: StoreInstances,
|
||||
{
|
||||
historyStore: historyConfig,
|
||||
nodes = [],
|
||||
edges = [],
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
}: WorkflowFlowOptions,
|
||||
) {
|
||||
const workflowWrapper = createWorkflowWrapper(stores, historyConfig)
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => React.createElement(
|
||||
workflowWrapper,
|
||||
null,
|
||||
React.createElement(
|
||||
'div',
|
||||
{ style: { width: 800, height: 600, ...canvasStyle } },
|
||||
React.createElement(
|
||||
ReactFlowProvider,
|
||||
null,
|
||||
React.createElement(ReactFlow, { fitView: true, ...reactFlowProps, nodes, edges }),
|
||||
children,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function renderWorkflowFlowComponent(
|
||||
ui: React.ReactElement,
|
||||
options?: WorkflowFlowComponentTestOptions,
|
||||
): WorkflowComponentTestResult {
|
||||
const {
|
||||
initialStoreState,
|
||||
hooksStoreProps,
|
||||
historyStore,
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
...renderOptions
|
||||
} = options ?? {}
|
||||
|
||||
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
|
||||
const wrapper = createWorkflowFlowWrapper(stores, {
|
||||
historyStore,
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
})
|
||||
|
||||
const renderResult = render(ui, { wrapper, ...renderOptions })
|
||||
return { ...renderResult, ...stores }
|
||||
}
|
||||
|
||||
export function renderWorkflowFlowHook<R, P = undefined>(
|
||||
hook: (props: P) => R,
|
||||
options?: WorkflowFlowHookTestOptions<P>,
|
||||
): WorkflowHookTestResult<R, P> {
|
||||
const {
|
||||
initialStoreState,
|
||||
hooksStoreProps,
|
||||
historyStore,
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
...rest
|
||||
} = options ?? {}
|
||||
|
||||
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
|
||||
const wrapper = createWorkflowFlowWrapper(stores, {
|
||||
historyStore,
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
})
|
||||
|
||||
const renderResult = renderHook(hook, { wrapper, ...rest })
|
||||
return { ...renderResult, ...stores }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderNodeComponent — convenience wrapper for node components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -0,0 +1,277 @@
|
||||
import type { TriggerWithProvider } from '../types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetLanguage, useLocale } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useFeaturedTriggersRecommendations } from '@/service/use-plugins'
|
||||
import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { Theme } from '@/types/app'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { useAvailableNodesMetaData } from '../../../workflow-app/hooks'
|
||||
import useNodes from '../../store/workflow/use-nodes'
|
||||
import { BlockEnum } from '../../types'
|
||||
import AllStartBlocks from '../all-start-blocks'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
useLocale: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplacePlugins: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useAllTriggerPlugins: vi.fn(),
|
||||
useInvalidateAllTriggerPlugins: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFeaturedTriggersRecommendations: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../store/workflow/use-nodes', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../workflow-app/hooks', () => ({
|
||||
useAvailableNodesMetaData: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/utils/var')>()
|
||||
return {
|
||||
...actual,
|
||||
getMarketplaceUrl: () => 'https://marketplace.test/start',
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseLocale = vi.mocked(useLocale)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
|
||||
const mockUseAllTriggerPlugins = vi.mocked(useAllTriggerPlugins)
|
||||
const mockUseInvalidateAllTriggerPlugins = vi.mocked(useInvalidateAllTriggerPlugins)
|
||||
const mockUseFeaturedTriggersRecommendations = vi.mocked(useFeaturedTriggersRecommendations)
|
||||
const mockUseNodes = vi.mocked(useNodes)
|
||||
const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData)
|
||||
|
||||
type UseMarketplacePluginsReturn = ReturnType<typeof useMarketplacePlugins>
|
||||
type UseAllTriggerPluginsReturn = ReturnType<typeof useAllTriggerPlugins>
|
||||
type UseFeaturedTriggersRecommendationsReturn = ReturnType<typeof useFeaturedTriggersRecommendations>
|
||||
|
||||
const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({
|
||||
id: 'provider-1',
|
||||
name: 'provider-one',
|
||||
author: 'Provider Author',
|
||||
description: { en_US: 'desc', zh_Hans: '描述' },
|
||||
icon: 'icon',
|
||||
icon_dark: 'icon-dark',
|
||||
label: { en_US: 'Provider One', zh_Hans: '提供商一' },
|
||||
type: CollectionType.trigger,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
plugin_id: 'plugin-1',
|
||||
plugin_unique_identifier: 'plugin-1@1.0.0',
|
||||
meta: { version: '1.0.0' },
|
||||
credentials_schema: [],
|
||||
subscription_constructor: null,
|
||||
subscription_schema: [],
|
||||
supported_creation_methods: [],
|
||||
events: [
|
||||
{
|
||||
name: 'created',
|
||||
author: 'Provider Author',
|
||||
label: { en_US: 'Created', zh_Hans: '创建' },
|
||||
description: { en_US: 'Created event', zh_Hans: '创建事件' },
|
||||
parameters: [],
|
||||
labels: [],
|
||||
output_schema: {},
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createSystemFeatures = (enableMarketplace: boolean) => ({
|
||||
...defaultSystemFeatures,
|
||||
enable_marketplace: enableMarketplace,
|
||||
})
|
||||
|
||||
const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
|
||||
systemFeatures: createSystemFeatures(enableMarketplace),
|
||||
setSystemFeatures: vi.fn(),
|
||||
})
|
||||
|
||||
const createMarketplacePluginsMock = (
|
||||
overrides: Partial<UseMarketplacePluginsReturn> = {},
|
||||
): UseMarketplacePluginsReturn => ({
|
||||
plugins: [],
|
||||
total: 0,
|
||||
resetPlugins: vi.fn(),
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
cancelQueryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
page: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createTriggerPluginsQueryResult = (
|
||||
data: TriggerWithProvider[],
|
||||
): UseAllTriggerPluginsReturn => ({
|
||||
data,
|
||||
error: null,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isFetching: false,
|
||||
isRefetching: false,
|
||||
isLoadingError: false,
|
||||
isRefetchError: false,
|
||||
isInitialLoading: false,
|
||||
isPaused: false,
|
||||
isEnabled: true,
|
||||
status: 'success',
|
||||
fetchStatus: 'idle',
|
||||
dataUpdatedAt: Date.now(),
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
errorUpdateCount: 0,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isPlaceholderData: false,
|
||||
isStale: false,
|
||||
refetch: vi.fn(),
|
||||
promise: Promise.resolve(data),
|
||||
} as UseAllTriggerPluginsReturn)
|
||||
|
||||
const createFeaturedTriggersRecommendationsMock = (
|
||||
overrides: Partial<UseFeaturedTriggersRecommendationsReturn> = {},
|
||||
): UseFeaturedTriggersRecommendationsReturn => ({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createAvailableNodesMetaData = (): ReturnType<typeof useAvailableNodesMetaData> => ({
|
||||
nodes: [],
|
||||
} as unknown as ReturnType<typeof useAvailableNodesMetaData>)
|
||||
|
||||
describe('AllStartBlocks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseLocale.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
|
||||
mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([createTriggerProvider()]))
|
||||
mockUseInvalidateAllTriggerPlugins.mockReturnValue(vi.fn())
|
||||
mockUseFeaturedTriggersRecommendations.mockReturnValue(createFeaturedTriggersRecommendationsMock())
|
||||
mockUseNodes.mockReturnValue([])
|
||||
mockUseAvailableNodesMetaData.mockReturnValue(createAvailableNodesMetaData())
|
||||
})
|
||||
|
||||
// The combined start tab should merge built-in blocks, trigger plugins, and marketplace states.
|
||||
describe('Content Rendering', () => {
|
||||
it('should render start blocks and trigger plugin actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<AllStartBlocks
|
||||
searchText=""
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={[BlockEnum.Start, BlockEnum.TriggerPlugin]}
|
||||
allowUserInputSelection
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.tabs.allTriggers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
|
||||
expect(screen.getByText('Provider One')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.blocks.start'))
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start)
|
||||
|
||||
await user.click(screen.getByText('Provider One'))
|
||||
await user.click(screen.getByText('Created'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({
|
||||
provider_id: 'provider-one',
|
||||
event_name: 'created',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show marketplace footer when marketplace is enabled without filters', async () => {
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
|
||||
|
||||
render(
|
||||
<AllStartBlocks
|
||||
searchText=""
|
||||
onSelect={vi.fn()}
|
||||
availableBlocksTypes={[BlockEnum.TriggerPlugin]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('link', { name: /plugin\.findMoreInMarketplace/ })).toHaveAttribute('href', 'https://marketplace.test/start')
|
||||
})
|
||||
})
|
||||
|
||||
// Empty filter states should surface the request-to-community fallback.
|
||||
describe('Filtered Empty State', () => {
|
||||
it('should query marketplace and show the no-results state when filters have no matches', async () => {
|
||||
const queryPluginsWithDebounced = vi.fn()
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
|
||||
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({
|
||||
queryPluginsWithDebounced,
|
||||
}))
|
||||
mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([]))
|
||||
|
||||
render(
|
||||
<AllStartBlocks
|
||||
searchText="missing"
|
||||
tags={['webhook']}
|
||||
onSelect={vi.fn()}
|
||||
availableBlocksTypes={[BlockEnum.TriggerPlugin]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryPluginsWithDebounced).toHaveBeenCalledWith({
|
||||
query: 'missing',
|
||||
tags: ['webhook'],
|
||||
category: 'trigger',
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'workflow.tabs.requestToCommunity' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,186 @@
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { BlockEnum } from '../../types'
|
||||
import DataSources from '../data-sources'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplacePlugins: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
|
||||
|
||||
type UseMarketplacePluginsReturn = ReturnType<typeof useMarketplacePlugins>
|
||||
|
||||
const createToolProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
|
||||
id: 'langgenius/file',
|
||||
name: 'file',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'desc', zh_Hans: '描述' },
|
||||
icon: 'icon',
|
||||
label: { en_US: 'File Source', zh_Hans: '文件源' },
|
||||
type: CollectionType.datasource,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
plugin_id: 'langgenius/file',
|
||||
meta: { version: '1.0.0' },
|
||||
tools: [
|
||||
{
|
||||
name: 'local-file',
|
||||
author: 'Dify',
|
||||
label: { en_US: 'Local File', zh_Hans: '本地文件' },
|
||||
description: { en_US: 'Load local files', zh_Hans: '加载本地文件' },
|
||||
parameters: [],
|
||||
labels: [],
|
||||
output_schema: {},
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createSystemFeatures = (enableMarketplace: boolean) => ({
|
||||
...defaultSystemFeatures,
|
||||
enable_marketplace: enableMarketplace,
|
||||
})
|
||||
|
||||
const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
|
||||
systemFeatures: createSystemFeatures(enableMarketplace),
|
||||
setSystemFeatures: vi.fn(),
|
||||
})
|
||||
|
||||
const createMarketplacePluginsMock = (
|
||||
overrides: Partial<UseMarketplacePluginsReturn> = {},
|
||||
): UseMarketplacePluginsReturn => ({
|
||||
plugins: [],
|
||||
total: 0,
|
||||
resetPlugins: vi.fn(),
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
cancelQueryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
page: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DataSources', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
|
||||
})
|
||||
|
||||
// Data source tools should filter by search and normalize the default value payload.
|
||||
describe('Selection', () => {
|
||||
it('should add default file extensions for the built-in local file data source', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<DataSources
|
||||
searchText=""
|
||||
onSelect={onSelect}
|
||||
dataSources={[createToolProvider()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('File Source'))
|
||||
await user.click(screen.getByText('Local File'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.DataSource, expect.objectContaining({
|
||||
provider_name: 'file',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
fileExtensions: expect.arrayContaining(['txt', 'pdf', 'md']),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should filter providers by search text', () => {
|
||||
render(
|
||||
<DataSources
|
||||
searchText="searchable"
|
||||
onSelect={vi.fn()}
|
||||
dataSources={[
|
||||
createToolProvider({
|
||||
id: 'searchable-provider',
|
||||
name: 'searchable-provider',
|
||||
label: { en_US: 'Searchable Source', zh_Hans: '可搜索源' },
|
||||
tools: [{
|
||||
name: 'searchable-tool',
|
||||
author: 'Dify',
|
||||
label: { en_US: 'Searchable Tool', zh_Hans: '可搜索工具' },
|
||||
description: { en_US: 'desc', zh_Hans: '描述' },
|
||||
parameters: [],
|
||||
labels: [],
|
||||
output_schema: {},
|
||||
}],
|
||||
}),
|
||||
createToolProvider({
|
||||
id: 'other-provider',
|
||||
name: 'other-provider',
|
||||
label: { en_US: 'Other Source', zh_Hans: '其他源' },
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Searchable Source')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Other Source')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Marketplace search should only run when enabled and a search term is present.
|
||||
describe('Marketplace Search', () => {
|
||||
it('should query marketplace plugins for datasource search results', async () => {
|
||||
const queryPluginsWithDebounced = vi.fn()
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
|
||||
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({
|
||||
queryPluginsWithDebounced,
|
||||
}))
|
||||
|
||||
render(
|
||||
<DataSources
|
||||
searchText="invoice"
|
||||
onSelect={vi.fn()}
|
||||
dataSources={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryPluginsWithDebounced).toHaveBeenCalledWith({
|
||||
query: 'invoice',
|
||||
category: PluginCategoryEnum.datasource,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,197 @@
|
||||
import type { TriggerWithProvider } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { PluginCategoryEnum, SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { BlockEnum } from '../../types'
|
||||
import FeaturedTriggers from '../featured-triggers'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({
|
||||
default: () => <div data-testid="marketplace-action" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: () => <div data-testid="install-from-marketplace" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/utils/var')>()
|
||||
return {
|
||||
...actual,
|
||||
getMarketplaceUrl: () => 'https://marketplace.test/triggers',
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'trigger',
|
||||
org: 'org',
|
||||
author: 'author',
|
||||
name: 'trigger-plugin',
|
||||
plugin_id: 'plugin-1',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'plugin-1@1.0.0',
|
||||
icon: 'icon',
|
||||
verified: true,
|
||||
label: { en_US: 'Plugin One', zh_Hans: '插件一' },
|
||||
brief: { en_US: 'Brief', zh_Hans: '简介' },
|
||||
description: { en_US: 'Plugin description', zh_Hans: '插件描述' },
|
||||
introduction: 'Intro',
|
||||
repository: 'https://example.com',
|
||||
category: PluginCategoryEnum.trigger,
|
||||
install_count: 12,
|
||||
endpoint: { settings: [] },
|
||||
tags: [{ name: 'tag' }],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({
|
||||
id: 'provider-1',
|
||||
name: 'provider-one',
|
||||
author: 'Provider Author',
|
||||
description: { en_US: 'desc', zh_Hans: '描述' },
|
||||
icon: 'icon',
|
||||
icon_dark: 'icon-dark',
|
||||
label: { en_US: 'Provider One', zh_Hans: '提供商一' },
|
||||
type: CollectionType.trigger,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
plugin_id: 'plugin-1',
|
||||
plugin_unique_identifier: 'plugin-1@1.0.0',
|
||||
meta: { version: '1.0.0' },
|
||||
credentials_schema: [],
|
||||
subscription_constructor: null,
|
||||
subscription_schema: [],
|
||||
supported_creation_methods: [SupportedCreationMethods.MANUAL],
|
||||
events: [
|
||||
{
|
||||
name: 'created',
|
||||
author: 'Provider Author',
|
||||
label: { en_US: 'Created', zh_Hans: '创建' },
|
||||
description: { en_US: 'Created event', zh_Hans: '创建事件' },
|
||||
parameters: [],
|
||||
labels: [],
|
||||
output_schema: {},
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FeaturedTriggers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
// The section should persist collapse state and allow expanding recommended rows.
|
||||
describe('Visibility Controls', () => {
|
||||
it('should persist collapse state in localStorage', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<FeaturedTriggers
|
||||
plugins={[]}
|
||||
providerMap={new Map()}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ }))
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).not.toBeInTheDocument()
|
||||
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('workflow_triggers_featured_collapsed', 'true')
|
||||
})
|
||||
|
||||
it('should show more and show less across installed providers', async () => {
|
||||
const user = userEvent.setup()
|
||||
const providers = Array.from({ length: 6 }).map((_, index) => createTriggerProvider({
|
||||
id: `provider-${index}`,
|
||||
name: `provider-${index}`,
|
||||
label: { en_US: `Provider ${index}`, zh_Hans: `提供商${index}` },
|
||||
plugin_id: `plugin-${index}`,
|
||||
plugin_unique_identifier: `plugin-${index}@1.0.0`,
|
||||
}))
|
||||
const providerMap = new Map(providers.map(provider => [provider.plugin_id!, provider]))
|
||||
const plugins = providers.map(provider => createPlugin({
|
||||
plugin_id: provider.plugin_id!,
|
||||
latest_package_identifier: provider.plugin_unique_identifier,
|
||||
}))
|
||||
|
||||
render(
|
||||
<FeaturedTriggers
|
||||
plugins={plugins}
|
||||
providerMap={providerMap}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Provider 4')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.tabs.showMoreFeatured'))
|
||||
expect(screen.getByText('Provider 5')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.tabs.showLessFeatured'))
|
||||
expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Rendering should cover the empty state link and installed trigger selection.
|
||||
describe('Rendering and Selection', () => {
|
||||
it('should render the empty state link when there are no featured plugins', () => {
|
||||
render(
|
||||
<FeaturedTriggers
|
||||
plugins={[]}
|
||||
providerMap={new Map()}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).toHaveAttribute('href', 'https://marketplace.test/triggers')
|
||||
})
|
||||
|
||||
it('should select an installed trigger event from the featured list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const provider = createTriggerProvider()
|
||||
|
||||
render(
|
||||
<FeaturedTriggers
|
||||
plugins={[createPlugin({ plugin_id: 'plugin-1', latest_package_identifier: 'plugin-1@1.0.0' })]}
|
||||
providerMap={new Map([
|
||||
['plugin-1', provider],
|
||||
['plugin-1@1.0.0', provider],
|
||||
])}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Provider One'))
|
||||
await user.click(screen.getByText('Created'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({
|
||||
provider_id: 'provider-one',
|
||||
event_name: 'created',
|
||||
event_label: 'Created',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { CollectionType } from '../../../tools/types'
|
||||
import IndexBar, {
|
||||
CUSTOM_GROUP_NAME,
|
||||
DATA_SOURCE_GROUP_NAME,
|
||||
groupItems,
|
||||
WORKFLOW_GROUP_NAME,
|
||||
} from '../index-bar'
|
||||
|
||||
const createToolProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
author: 'Author',
|
||||
description: { en_US: 'desc', zh_Hans: '描述' },
|
||||
icon: 'icon',
|
||||
label: { en_US: 'Alpha', zh_Hans: '甲' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
tools: [],
|
||||
meta: { version: '1.0.0' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('IndexBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Grouping should normalize Chinese initials, custom groups, and hash ordering.
|
||||
describe('groupItems', () => {
|
||||
it('should group providers by first letter and move hash to the end', () => {
|
||||
const items: ToolWithProvider[] = [
|
||||
createToolProvider({
|
||||
id: 'alpha',
|
||||
label: { en_US: 'Alpha', zh_Hans: '甲' },
|
||||
type: CollectionType.builtIn,
|
||||
author: 'Builtin',
|
||||
}),
|
||||
createToolProvider({
|
||||
id: 'custom',
|
||||
label: { en_US: '1Custom', zh_Hans: '1自定义' },
|
||||
type: CollectionType.custom,
|
||||
author: 'Custom',
|
||||
}),
|
||||
createToolProvider({
|
||||
id: 'workflow',
|
||||
label: { en_US: '中文工作流', zh_Hans: '中文工作流' },
|
||||
type: CollectionType.workflow,
|
||||
author: 'Workflow',
|
||||
}),
|
||||
createToolProvider({
|
||||
id: 'source',
|
||||
label: { en_US: 'Data Source', zh_Hans: '数据源' },
|
||||
type: CollectionType.datasource,
|
||||
author: 'Data',
|
||||
}),
|
||||
]
|
||||
|
||||
const result = groupItems(items, item => item.label.zh_Hans[0] || item.label.en_US[0] || '')
|
||||
|
||||
expect(result.letters).toEqual(['J', 'S', 'Z', '#'])
|
||||
expect(result.groups.J.Builtin).toHaveLength(1)
|
||||
expect(result.groups.Z[WORKFLOW_GROUP_NAME]).toHaveLength(1)
|
||||
expect(result.groups.S[DATA_SOURCE_GROUP_NAME]).toHaveLength(1)
|
||||
expect(result.groups['#'][CUSTOM_GROUP_NAME]).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Clicking a letter should scroll the matching section into view.
|
||||
describe('Rendering', () => {
|
||||
it('should call scrollIntoView for the selected letter', async () => {
|
||||
const user = userEvent.setup()
|
||||
const scrollIntoView = vi.fn()
|
||||
const itemRefs = {
|
||||
current: {
|
||||
A: { scrollIntoView } as unknown as HTMLElement,
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<IndexBar
|
||||
letters={['A']}
|
||||
itemRefs={itemRefs}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('A'))
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,80 @@
|
||||
import type { CommonNodeType } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { useAvailableNodesMetaData } from '../../../workflow-app/hooks'
|
||||
import { BlockEnum } from '../../types'
|
||||
import StartBlocks from '../start-blocks'
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../workflow-app/hooks', () => ({
|
||||
useAvailableNodesMetaData: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseNodes = vi.mocked(useNodes)
|
||||
const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData)
|
||||
|
||||
const createNode = (type: BlockEnum) => ({
|
||||
data: { type } as Pick<CommonNodeType, 'type'>,
|
||||
}) as ReturnType<typeof useNodes>[number]
|
||||
|
||||
const createAvailableNodesMetaData = (): ReturnType<typeof useAvailableNodesMetaData> => ({
|
||||
nodes: [],
|
||||
} as unknown as ReturnType<typeof useAvailableNodesMetaData>)
|
||||
|
||||
describe('StartBlocks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodes.mockReturnValue([])
|
||||
mockUseAvailableNodesMetaData.mockReturnValue(createAvailableNodesMetaData())
|
||||
})
|
||||
|
||||
// Start block selection should respect available types and workflow state.
|
||||
describe('Filtering and Selection', () => {
|
||||
it('should render available start blocks and forward selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const onContentStateChange = vi.fn()
|
||||
|
||||
render(
|
||||
<StartBlocks
|
||||
searchText=""
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={[BlockEnum.Start, BlockEnum.TriggerWebhook]}
|
||||
onContentStateChange={onContentStateChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.blocks.trigger-webhook')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.blocks.originalStartNode')).toBeInTheDocument()
|
||||
expect(onContentStateChange).toHaveBeenCalledWith(true)
|
||||
|
||||
await user.click(screen.getByText('workflow.blocks.start'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start)
|
||||
})
|
||||
|
||||
it('should hide user input when a start node already exists or hideUserInput is enabled', () => {
|
||||
const onContentStateChange = vi.fn()
|
||||
mockUseNodes.mockReturnValue([createNode(BlockEnum.Start)])
|
||||
|
||||
const { container } = render(
|
||||
<StartBlocks
|
||||
searchText=""
|
||||
onSelect={vi.fn()}
|
||||
availableBlocksTypes={[BlockEnum.Start]}
|
||||
onContentStateChange={onContentStateChange}
|
||||
hideUserInput
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByText('workflow.blocks.start')).not.toBeInTheDocument()
|
||||
expect(onContentStateChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,340 +0,0 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { resetReactFlowMockState, rfState } from './__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowComponent } from './__tests__/workflow-test-env'
|
||||
import EdgeContextmenu from './edge-contextmenu'
|
||||
import { useEdgesInteractions } from './hooks/use-edges-interactions'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('./__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
const mockSaveStateToHistory = vi.fn()
|
||||
|
||||
vi.mock('./hooks/use-workflow-history', () => ({
|
||||
useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }),
|
||||
WorkflowHistoryEvent: {
|
||||
EdgeDelete: 'EdgeDelete',
|
||||
EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
|
||||
EdgeSourceHandleChange: 'EdgeSourceHandleChange',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: () => false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./utils')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./hooks', async () => {
|
||||
const { useEdgesInteractions } = await import('./hooks/use-edges-interactions')
|
||||
const { usePanelInteractions } = await import('./hooks/use-panel-interactions')
|
||||
|
||||
return {
|
||||
useEdgesInteractions,
|
||||
usePanelInteractions,
|
||||
}
|
||||
})
|
||||
|
||||
describe('EdgeContextmenu', () => {
|
||||
const hooksStoreProps = {
|
||||
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
type TestNode = typeof rfState.nodes[number] & {
|
||||
selected?: boolean
|
||||
data: {
|
||||
selected?: boolean
|
||||
_isBundled?: boolean
|
||||
}
|
||||
}
|
||||
type TestEdge = typeof rfState.edges[number] & {
|
||||
selected?: boolean
|
||||
}
|
||||
const createNode = (id: string, selected = false): TestNode => ({
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
data: { selected },
|
||||
selected,
|
||||
})
|
||||
const createEdge = (id: string, selected = false): TestEdge => ({
|
||||
id,
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
data: {},
|
||||
selected,
|
||||
})
|
||||
|
||||
const EdgeMenuHarness = () => {
|
||||
const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace')
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
handleEdgeDelete()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleEdgeDelete])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Right-click edge e1"
|
||||
onContextMenu={e => handleEdgeContextMenu(e as never, rfState.edges.find(edge => edge.id === 'e1') as never)}
|
||||
>
|
||||
edge-e1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Right-click edge e2"
|
||||
onContextMenu={e => handleEdgeContextMenu(e as never, rfState.edges.find(edge => edge.id === 'e2') as never)}
|
||||
>
|
||||
edge-e2
|
||||
</button>
|
||||
<EdgeContextmenu />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
createNode('n1'),
|
||||
createNode('n2'),
|
||||
]
|
||||
rfState.edges = [
|
||||
createEdge('e1', true) as typeof rfState.edges[number] & { selected: boolean },
|
||||
createEdge('e2'),
|
||||
]
|
||||
rfState.setNodes.mockImplementation((nextNodes) => {
|
||||
rfState.nodes = nextNodes as typeof rfState.nodes
|
||||
})
|
||||
rfState.setEdges.mockImplementation((nextEdges) => {
|
||||
rfState.edges = nextEdges as typeof rfState.edges
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render when edgeMenu is absent', () => {
|
||||
renderWorkflowComponent(<EdgeContextmenu />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should delete the menu edge and close the menu when another edge is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
;(rfState.edges[0] as Record<string, unknown>).selected = true
|
||||
;(rfState.edges[1] as Record<string, unknown>).selected = false
|
||||
|
||||
const { store } = renderWorkflowComponent(<EdgeContextmenu />, {
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'e2',
|
||||
},
|
||||
},
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
|
||||
expect(screen.getByText(/^del$/i)).toBeInTheDocument()
|
||||
|
||||
await user.click(deleteAction)
|
||||
|
||||
const updatedEdges = rfState.setEdges.mock.calls.at(-1)?.[0]
|
||||
expect(updatedEdges).toHaveLength(1)
|
||||
expect(updatedEdges[0].id).toBe('e1')
|
||||
expect(updatedEdges[0].selected).toBe(true)
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render the menu when the referenced edge no longer exists', () => {
|
||||
renderWorkflowComponent(<EdgeContextmenu />, {
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'missing-edge',
|
||||
},
|
||||
},
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the edge menu at the right-click position', async () => {
|
||||
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
|
||||
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument()
|
||||
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
x: 320,
|
||||
y: 180,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should delete the right-clicked edge and close the menu when delete is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
})
|
||||
|
||||
await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(rfState.edges.map(edge => edge.id)).toEqual(['e1'])
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Delete', 'Delete'],
|
||||
['Backspace', 'Backspace'],
|
||||
])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => {
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
rfState.nodes = [createNode('n1', true), createNode('n2')]
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 240,
|
||||
clientY: 120,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(rfState.edges.map(edge => edge.id)).toEqual(['e1'])
|
||||
expect(rfState.nodes.map(node => node.id)).toEqual(['n1', 'n2'])
|
||||
expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected)).toBe(true)
|
||||
})
|
||||
|
||||
it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => {
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
rfState.nodes = [
|
||||
{ ...createNode('n1', true), data: { selected: true, _isBundled: true } },
|
||||
{ ...createNode('n2', true), data: { selected: true, _isBundled: true } },
|
||||
]
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
|
||||
clientX: 200,
|
||||
clientY: 100,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Delete' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(rfState.edges.map(edge => edge.id)).toEqual(['e2'])
|
||||
expect(rfState.nodes).toHaveLength(2)
|
||||
expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected && !node.data._isBundled)).toBe(true)
|
||||
})
|
||||
|
||||
it('should retarget the menu and selected edge when right-clicking a different edge', async () => {
|
||||
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
|
||||
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
const edgeOneButton = screen.getByLabelText('Right-click edge e1')
|
||||
const edgeTwoButton = screen.getByLabelText('Right-click edge e2')
|
||||
|
||||
fireEvent.contextMenu(edgeOneButton, {
|
||||
clientX: 80,
|
||||
clientY: 60,
|
||||
})
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.contextMenu(edgeTwoButton, {
|
||||
clientX: 360,
|
||||
clientY: 240,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('menu')).toHaveLength(1)
|
||||
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
x: 360,
|
||||
y: 240,
|
||||
}))
|
||||
expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e1')?.selected).toBe(false)
|
||||
expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e2')?.selected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide the menu when the target edge disappears after opening it', async () => {
|
||||
const { store } = renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
|
||||
clientX: 160,
|
||||
clientY: 100,
|
||||
})
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
rfState.edges = [createEdge('e2')]
|
||||
store.setState({
|
||||
edgeMenu: {
|
||||
clientX: 160,
|
||||
clientY: 100,
|
||||
edgeId: 'e1',
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,8 +2,8 @@ import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import RunMode from './run-mode'
|
||||
import { TriggerType } from './test-run-menu'
|
||||
import RunMode from '../run-mode'
|
||||
import { TriggerType } from '../test-run-menu'
|
||||
|
||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||
const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn()
|
||||
@ -42,7 +42,7 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-dynamic-test-run-options', () => ({
|
||||
vi.mock('../../hooks/use-dynamic-test-run-options', () => ({
|
||||
useDynamicTestRunOptions: () => mockDynamicOptions,
|
||||
}))
|
||||
|
||||
@ -72,8 +72,8 @@ vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
|
||||
StopCircle: () => <span data-testid="stop-circle" />,
|
||||
}))
|
||||
|
||||
vi.mock('./test-run-menu', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./test-run-menu')>()
|
||||
vi.mock('../test-run-menu', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../test-run-menu')>()
|
||||
return {
|
||||
...actual,
|
||||
default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => {
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import WorkflowChecklist from './index'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import WorkflowChecklist from '../index'
|
||||
|
||||
let mockChecklistItems = [
|
||||
{
|
||||
@ -40,7 +40,7 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useChecklist: () => mockChecklistItems,
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
@ -57,11 +57,11 @@ vi.mock('@/app/components/base/ui/popover', () => ({
|
||||
PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>,
|
||||
}))
|
||||
|
||||
vi.mock('./plugin-group', () => ({
|
||||
vi.mock('../plugin-group', () => ({
|
||||
ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => <div data-testid="plugin-group">{items.map(item => item.title).join(',')}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./node-group', () => ({
|
||||
vi.mock('../node-group', () => ({
|
||||
ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => (
|
||||
<button data-testid={`node-group-${item.title}`} onClick={() => onItemClick(item)}>
|
||||
{item.title}
|
||||
@ -1,12 +1,12 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { ChecklistNodeGroup } from './node-group'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import { ChecklistNodeGroup } from '../node-group'
|
||||
|
||||
vi.mock('../../block-icon', () => ({
|
||||
vi.mock('../../../block-icon', () => ({
|
||||
default: () => <div data-testid="block-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('./item-indicator', () => ({
|
||||
vi.mock('../item-indicator', () => ({
|
||||
ItemIndicator: () => <div data-testid="item-indicator" />,
|
||||
}))
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import type { ChecklistItem } from '../../../hooks/use-checklist'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Popover, PopoverContent } from '@/app/components/base/ui/popover'
|
||||
import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { ChecklistPluginGroup } from './plugin-group'
|
||||
import { useStore as usePluginDependencyStore } from '../../../plugin-dependency/store'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import { ChecklistPluginGroup } from '../plugin-group'
|
||||
|
||||
const createChecklistItem = (overrides: Partial<ChecklistItem> = {}): ChecklistItem => ({
|
||||
id: 'node-1',
|
||||
@ -1,10 +1,17 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import type { Node } from '../../types'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useAutoGenerateWebhookUrl } from '../use-auto-generate-webhook-url'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
type WebhookFlowNode = Node & {
|
||||
data: NonNullable<Node['data']> & {
|
||||
webhook_url?: string
|
||||
webhook_debug_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/app/store', async () =>
|
||||
(await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' }))
|
||||
@ -15,13 +22,29 @@ vi.mock('@/service/apps', () => ({
|
||||
}))
|
||||
|
||||
describe('useAutoGenerateWebhookUrl', () => {
|
||||
const createFlowNodes = (): WebhookFlowNode[] => [
|
||||
createNode({
|
||||
id: 'webhook-1',
|
||||
data: { type: BlockEnum.TriggerWebhook, webhook_url: '' },
|
||||
}) as WebhookFlowNode,
|
||||
createNode({
|
||||
id: 'code-1',
|
||||
position: { x: 300, y: 0 },
|
||||
data: { type: BlockEnum.Code },
|
||||
}) as WebhookFlowNode,
|
||||
]
|
||||
|
||||
const renderAutoGenerateWebhookUrlHook = () =>
|
||||
renderWorkflowFlowHook(() => ({
|
||||
autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(),
|
||||
nodes: useNodes<WebhookFlowNode>(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges: [],
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'webhook-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.TriggerWebhook, webhook_url: '' } },
|
||||
{ id: 'code-1', position: { x: 300, y: 0 }, data: { type: BlockEnum.Code } },
|
||||
]
|
||||
})
|
||||
|
||||
it('should fetch and set webhook URL for a webhook trigger node', async () => {
|
||||
@ -30,38 +53,63 @@ describe('useAutoGenerateWebhookUrl', () => {
|
||||
webhook_debug_url: 'https://example.com/webhook-debug',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
|
||||
await result.current('webhook-1')
|
||||
const { result } = renderAutoGenerateWebhookUrlHook()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.autoGenerateWebhookUrl('webhook-1')
|
||||
})
|
||||
|
||||
expect(mockFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-123', nodeId: 'webhook-1' })
|
||||
expect(rfState.setNodes).toHaveBeenCalledOnce()
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
const webhookNode = updatedNodes.find((n: { id: string }) => n.id === 'webhook-1')
|
||||
expect(webhookNode.data.webhook_url).toBe('https://example.com/webhook')
|
||||
expect(webhookNode.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
|
||||
await waitFor(() => {
|
||||
const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined
|
||||
expect(webhookNode?.data.webhook_url).toBe('https://example.com/webhook')
|
||||
expect(webhookNode?.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not fetch when node is not a webhook trigger', async () => {
|
||||
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
|
||||
await result.current('code-1')
|
||||
const { result } = renderAutoGenerateWebhookUrlHook()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.autoGenerateWebhookUrl('code-1')
|
||||
})
|
||||
|
||||
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
|
||||
expect(rfState.setNodes).not.toHaveBeenCalled()
|
||||
|
||||
const codeNode = result.current.nodes.find(node => node.id === 'code-1') as WebhookFlowNode | undefined
|
||||
expect(codeNode?.data.webhook_url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not fetch when node does not exist', async () => {
|
||||
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
|
||||
await result.current('nonexistent')
|
||||
const { result } = renderAutoGenerateWebhookUrlHook()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.autoGenerateWebhookUrl('nonexistent')
|
||||
})
|
||||
|
||||
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not fetch when webhook_url already exists', async () => {
|
||||
rfState.nodes[0].data.webhook_url = 'https://existing.com/webhook'
|
||||
const { result } = renderWorkflowFlowHook(() => ({
|
||||
autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(),
|
||||
}), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'webhook-1',
|
||||
data: {
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
webhook_url: 'https://existing.com/webhook',
|
||||
},
|
||||
}) as WebhookFlowNode,
|
||||
],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
|
||||
await result.current('webhook-1')
|
||||
await act(async () => {
|
||||
await result.current.autoGenerateWebhookUrl('webhook-1')
|
||||
})
|
||||
|
||||
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
@ -70,14 +118,18 @@ describe('useAutoGenerateWebhookUrl', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockFetchWebhookUrl.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
|
||||
await result.current('webhook-1')
|
||||
const { result } = renderAutoGenerateWebhookUrlHook()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.autoGenerateWebhookUrl('webhook-1')
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to auto-generate webhook URL:',
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(rfState.setNodes).not.toHaveBeenCalled()
|
||||
const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined
|
||||
expect(webhookNode?.data.webhook_url).toBe('')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useEdges, useNodes } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useEdgesInteractions } from '../use-edges-interactions'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
// useWorkflowHistory uses a debounced save — mock for synchronous assertions
|
||||
const mockSaveStateToHistory = vi.fn()
|
||||
vi.mock('../use-workflow-history', () => ({
|
||||
@ -28,12 +27,67 @@ vi.mock('../../utils', () => ({
|
||||
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
// useNodesSyncDraft is used REAL — via renderWorkflowHook + hooksStoreProps
|
||||
function renderEdgesInteractions() {
|
||||
type EdgeRuntimeState = {
|
||||
_hovering?: boolean
|
||||
_isBundled?: boolean
|
||||
}
|
||||
|
||||
type NodeRuntimeState = {
|
||||
selected?: boolean
|
||||
_isBundled?: boolean
|
||||
}
|
||||
|
||||
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
function createFlowNodes() {
|
||||
return [
|
||||
createNode({ id: 'n1' }),
|
||||
createNode({ id: 'n2', position: { x: 100, y: 0 } }),
|
||||
]
|
||||
}
|
||||
|
||||
function createFlowEdges() {
|
||||
return [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-a',
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-b',
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function renderEdgesInteractions(options?: {
|
||||
nodes?: ReturnType<typeof createFlowNodes>
|
||||
edges?: ReturnType<typeof createFlowEdges>
|
||||
initialStoreState?: Record<string, unknown>
|
||||
}) {
|
||||
const mockDoSync = vi.fn().mockResolvedValue(undefined)
|
||||
const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {}
|
||||
|
||||
return {
|
||||
...renderWorkflowHook(() => useEdgesInteractions(), {
|
||||
...renderWorkflowFlowHook(() => ({
|
||||
...useEdgesInteractions(),
|
||||
nodes: useNodes(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes,
|
||||
edges,
|
||||
initialStoreState,
|
||||
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
|
||||
reactFlowProps: { fitView: false },
|
||||
}),
|
||||
mockDoSync,
|
||||
}
|
||||
@ -42,73 +96,105 @@ function renderEdgesInteractions() {
|
||||
describe('useEdgesInteractions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
mockReadOnly = false
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: 'n2', position: { x: 100, y: 0 }, data: {} },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false } },
|
||||
{ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false } },
|
||||
]
|
||||
})
|
||||
|
||||
it('handleEdgeEnter should set _hovering to true', () => {
|
||||
it('handleEdgeEnter should set _hovering to true', async () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated.find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(true)
|
||||
expect(updated.find((e: { id: string }) => e.id === 'e2').data._hovering).toBe(false)
|
||||
act(() => {
|
||||
result.current.handleEdgeEnter({} as never, result.current.edges[0] as never)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(true)
|
||||
expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e2'))._hovering).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('handleEdgeLeave should set _hovering to false', () => {
|
||||
rfState.edges[0].data._hovering = true
|
||||
it('handleEdgeLeave should set _hovering to false', async () => {
|
||||
const { result } = renderEdgesInteractions({
|
||||
edges: createFlowEdges().map(edge =>
|
||||
edge.id === 'e1'
|
||||
? createEdge({ ...edge, data: { ...edge.data, _hovering: true } })
|
||||
: edge,
|
||||
),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeLeave({} as never, result.current.edges[0] as never)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('handleEdgesChange should update edge.selected for select changes', async () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeLeave({} as never, rfState.edges[0] as never)
|
||||
|
||||
expect(rfState.setEdges.mock.calls[0][0].find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(false)
|
||||
act(() => {
|
||||
result.current.handleEdgesChange([
|
||||
{ type: 'select', id: 'e1', selected: true },
|
||||
{ type: 'select', id: 'e2', selected: false },
|
||||
])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(true)
|
||||
expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('handleEdgesChange should update edge.selected for select changes', () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgesChange([
|
||||
{ type: 'select', id: 'e1', selected: true },
|
||||
{ type: 'select', id: 'e2', selected: false },
|
||||
])
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(true)
|
||||
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false)
|
||||
})
|
||||
|
||||
it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', () => {
|
||||
it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', async () => {
|
||||
const preventDefault = vi.fn()
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { selected: true, _isBundled: true }, selected: true } as typeof rfState.nodes[number] & { selected: boolean },
|
||||
{ id: 'n2', position: { x: 100, y: 0 }, data: { _isBundled: true } },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false, _isBundled: true } },
|
||||
{ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false, _isBundled: true } },
|
||||
]
|
||||
const { result, store } = renderEdgesInteractions({
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { selected: true, _isBundled: true },
|
||||
selected: true,
|
||||
}),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 100, y: 0 },
|
||||
data: { _isBundled: true },
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-a',
|
||||
data: { _hovering: false, _isBundled: true },
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-b',
|
||||
data: { _hovering: false, _isBundled: true },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
result.current.handleEdgeContextMenu({
|
||||
preventDefault,
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
} as never, rfState.edges[1] as never)
|
||||
act(() => {
|
||||
result.current.handleEdgeContextMenu({
|
||||
preventDefault,
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
} as never, result.current.edges[1] as never)
|
||||
})
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(false)
|
||||
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(true)
|
||||
expect(updated.every((e: { data: { _isBundled?: boolean } }) => !e.data._isBundled)).toBe(true)
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updatedNodes.every((node: { data: { selected?: boolean, _isBundled?: boolean }, selected?: boolean }) => !node.data.selected && !node.selected && !node.data._isBundled)).toBe(true)
|
||||
await waitFor(() => {
|
||||
expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(false)
|
||||
expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(true)
|
||||
expect(result.current.edges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true)
|
||||
expect(result.current.nodes.every(node => !getNodeRuntimeState(node).selected && !node.selected && !getNodeRuntimeState(node)._isBundled)).toBe(true)
|
||||
})
|
||||
|
||||
expect(store.getState().edgeMenu).toEqual({
|
||||
clientX: 320,
|
||||
@ -120,70 +206,133 @@ describe('useEdgesInteractions', () => {
|
||||
expect(store.getState().selectionMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handleEdgeDelete should remove selected edge and trigger sync + history', () => {
|
||||
;(rfState.edges[0] as Record<string, unknown>).selected = true
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
store.setState({
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
it('handleEdgeDelete should remove selected edge and trigger sync + history', async () => {
|
||||
const { result, store } = renderEdgesInteractions({
|
||||
edges: [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-a',
|
||||
selected: true,
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-b',
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleEdgeDelete()
|
||||
act(() => {
|
||||
result.current.handleEdgeDelete()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.edges).toHaveLength(1)
|
||||
expect(result.current.edges[0]?.id).toBe('e2')
|
||||
})
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated).toHaveLength(1)
|
||||
expect(updated[0].id).toBe('e2')
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
})
|
||||
|
||||
it('handleEdgeDelete should do nothing when no edge is selected', () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeDelete()
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', () => {
|
||||
;(rfState.edges[0] as Record<string, unknown>).selected = true
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
store.setState({
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' },
|
||||
act(() => {
|
||||
result.current.handleEdgeDelete()
|
||||
})
|
||||
|
||||
result.current.handleEdgeDeleteById('e2')
|
||||
expect(result.current.edges).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', async () => {
|
||||
const { result, store } = renderEdgesInteractions({
|
||||
edges: [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-a',
|
||||
selected: true,
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-b',
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' },
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeDeleteById('e2')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.edges).toHaveLength(1)
|
||||
expect(result.current.edges[0]?.id).toBe('e1')
|
||||
expect(result.current.edges[0]?.selected).toBe(true)
|
||||
})
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated).toHaveLength(1)
|
||||
expect(updated[0].id).toBe('e1')
|
||||
expect(updated[0].selected).toBe(true)
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
})
|
||||
|
||||
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', () => {
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
store.setState({
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', async () => {
|
||||
const { result, store } = renderEdgesInteractions({
|
||||
initialStoreState: {
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.edges).toHaveLength(1)
|
||||
expect(result.current.edges[0]?.id).toBe('e2')
|
||||
})
|
||||
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated).toHaveLength(1)
|
||||
expect(updated[0].id).toBe('e2')
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch')
|
||||
})
|
||||
|
||||
it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', () => {
|
||||
rfState.edges = [
|
||||
{ id: 'n1-old-handle-n2-target', source: 'n1', target: 'n2', sourceHandle: 'old-handle', targetHandle: 'target', data: {} } as typeof rfState.edges[0],
|
||||
]
|
||||
it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', async () => {
|
||||
const { result } = renderEdgesInteractions({
|
||||
edges: [
|
||||
createEdge({
|
||||
id: 'n1-old-handle-n2-target',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'old-handle',
|
||||
targetHandle: 'target',
|
||||
data: {},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
|
||||
act(() => {
|
||||
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
|
||||
})
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated[0].sourceHandle).toBe('new-handle')
|
||||
expect(updated[0].id).toBe('n1-new-handle-n2-target')
|
||||
await waitFor(() => {
|
||||
expect(result.current.edges[0]?.sourceHandle).toBe('new-handle')
|
||||
expect(result.current.edges[0]?.id).toBe('n1-new-handle-n2-target')
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-only mode', () => {
|
||||
@ -193,38 +342,75 @@ describe('useEdgesInteractions', () => {
|
||||
|
||||
it('handleEdgeEnter should do nothing', () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeEnter({} as never, result.current.edges[0] as never)
|
||||
})
|
||||
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._hovering).toBe(false)
|
||||
})
|
||||
|
||||
it('handleEdgeDelete should do nothing', () => {
|
||||
;(rfState.edges[0] as Record<string, unknown>).selected = true
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeDelete()
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
const { result } = renderEdgesInteractions({
|
||||
edges: [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-a',
|
||||
selected: true,
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-b',
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeDelete()
|
||||
})
|
||||
|
||||
expect(result.current.edges).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handleEdgeDeleteById should do nothing', () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeDeleteById('e1')
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeDeleteById('e1')
|
||||
})
|
||||
|
||||
expect(result.current.edges).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handleEdgeContextMenu should do nothing', () => {
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
result.current.handleEdgeContextMenu({
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 200,
|
||||
clientY: 120,
|
||||
} as never, rfState.edges[0] as never)
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeContextMenu({
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 200,
|
||||
clientY: 120,
|
||||
} as never, result.current.edges[0] as never)
|
||||
})
|
||||
|
||||
expect(result.current.edges.every(edge => !edge.selected)).toBe(true)
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handleEdgeDeleteByDeleteBranch should do nothing', () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
|
||||
})
|
||||
|
||||
expect(result.current.edges).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,58 +1,52 @@
|
||||
import type * as React from 'react'
|
||||
import type { Node, OnSelectionChangeParams } from 'reactflow'
|
||||
import type { MockEdge, MockNode } from '../../__tests__/reactflow-mock-state'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import type { OnSelectionChangeParams } from 'reactflow'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useEdges, useNodes, useStoreApi } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useSelectionInteractions } from '../use-selection-interactions'
|
||||
|
||||
const rfStoreExtra = vi.hoisted(() => ({
|
||||
userSelectionRect: null as { x: number, y: number, width: number, height: number } | null,
|
||||
userSelectionActive: false,
|
||||
resetSelectedElements: vi.fn(),
|
||||
setState: vi.fn(),
|
||||
}))
|
||||
type BundledState = {
|
||||
_isBundled?: boolean
|
||||
}
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const mod = await import('../../__tests__/reactflow-mock-state')
|
||||
const base = mod.createReactFlowModuleMock()
|
||||
return {
|
||||
...base,
|
||||
useStoreApi: vi.fn(() => ({
|
||||
getState: () => ({
|
||||
getNodes: () => mod.rfState.nodes,
|
||||
setNodes: mod.rfState.setNodes,
|
||||
edges: mod.rfState.edges,
|
||||
setEdges: mod.rfState.setEdges,
|
||||
transform: mod.rfState.transform,
|
||||
userSelectionRect: rfStoreExtra.userSelectionRect,
|
||||
userSelectionActive: rfStoreExtra.userSelectionActive,
|
||||
resetSelectedElements: rfStoreExtra.resetSelectedElements,
|
||||
}),
|
||||
setState: rfStoreExtra.setState,
|
||||
subscribe: vi.fn().mockReturnValue(vi.fn()),
|
||||
})),
|
||||
}
|
||||
})
|
||||
const getBundledState = (item?: { data?: unknown }): BundledState =>
|
||||
(item?.data ?? {}) as BundledState
|
||||
|
||||
function createFlowNodes() {
|
||||
return [
|
||||
createNode({ id: 'n1', data: { _isBundled: true } }),
|
||||
createNode({ id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } }),
|
||||
createNode({ id: 'n3', position: { x: 200, y: 200 }, data: {} }),
|
||||
]
|
||||
}
|
||||
|
||||
function createFlowEdges() {
|
||||
return [
|
||||
createEdge({ id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } }),
|
||||
createEdge({ id: 'e2', source: 'n2', target: 'n3', data: {} }),
|
||||
]
|
||||
}
|
||||
|
||||
function renderSelectionInteractions(initialStoreState?: Record<string, unknown>) {
|
||||
return renderWorkflowFlowHook(() => ({
|
||||
...useSelectionInteractions(),
|
||||
nodes: useNodes(),
|
||||
edges: useEdges(),
|
||||
reactFlowStore: useStoreApi(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges: createFlowEdges(),
|
||||
reactFlowProps: { fitView: false },
|
||||
initialStoreState,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useSelectionInteractions', () => {
|
||||
let container: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfStoreExtra.userSelectionRect = null
|
||||
rfStoreExtra.userSelectionActive = false
|
||||
rfStoreExtra.resetSelectedElements = vi.fn()
|
||||
rfStoreExtra.setState.mockReset()
|
||||
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { _isBundled: true } },
|
||||
{ id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } },
|
||||
{ id: 'n3', position: { x: 200, y: 200 }, data: {} },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } },
|
||||
{ id: 'e2', source: 'n2', target: 'n3', data: {} },
|
||||
]
|
||||
vi.clearAllMocks()
|
||||
|
||||
container = document.createElement('div')
|
||||
container.id = 'workflow-container'
|
||||
@ -73,110 +67,137 @@ describe('useSelectionInteractions', () => {
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('handleSelectionStart should clear _isBundled from all nodes and edges', () => {
|
||||
const { result } = renderWorkflowHook(() => useSelectionInteractions())
|
||||
it('handleSelectionStart should clear _isBundled from all nodes and edges', async () => {
|
||||
const { result } = renderSelectionInteractions()
|
||||
|
||||
result.current.handleSelectionStart()
|
||||
act(() => {
|
||||
result.current.handleSelectionStart()
|
||||
})
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
|
||||
expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
|
||||
|
||||
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
|
||||
expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
|
||||
await waitFor(() => {
|
||||
expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true)
|
||||
expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('handleSelectionChange should mark selected nodes as bundled', () => {
|
||||
rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
|
||||
it('handleSelectionChange should mark selected nodes as bundled', async () => {
|
||||
const { result } = renderSelectionInteractions()
|
||||
|
||||
const { result } = renderWorkflowHook(() => useSelectionInteractions())
|
||||
act(() => {
|
||||
result.current.reactFlowStore.setState({
|
||||
userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
|
||||
} as never)
|
||||
})
|
||||
|
||||
result.current.handleSelectionChange({
|
||||
nodes: [{ id: 'n1' }, { id: 'n3' }],
|
||||
edges: [],
|
||||
} as unknown as OnSelectionChangeParams)
|
||||
act(() => {
|
||||
result.current.handleSelectionChange({
|
||||
nodes: [{ id: 'n1' }, { id: 'n3' }],
|
||||
edges: [],
|
||||
} as unknown as OnSelectionChangeParams)
|
||||
})
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
|
||||
expect(updatedNodes.find(n => n.id === 'n1')!.data._isBundled).toBe(true)
|
||||
expect(updatedNodes.find(n => n.id === 'n2')!.data._isBundled).toBe(false)
|
||||
expect(updatedNodes.find(n => n.id === 'n3')!.data._isBundled).toBe(true)
|
||||
await waitFor(() => {
|
||||
expect(getBundledState(result.current.nodes.find(node => node.id === 'n1'))._isBundled).toBe(true)
|
||||
expect(getBundledState(result.current.nodes.find(node => node.id === 'n2'))._isBundled).toBe(false)
|
||||
expect(getBundledState(result.current.nodes.find(node => node.id === 'n3'))._isBundled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('handleSelectionChange should mark selected edges', () => {
|
||||
rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
|
||||
it('handleSelectionChange should mark selected edges', async () => {
|
||||
const { result } = renderSelectionInteractions()
|
||||
|
||||
const { result } = renderWorkflowHook(() => useSelectionInteractions())
|
||||
act(() => {
|
||||
result.current.reactFlowStore.setState({
|
||||
userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
|
||||
} as never)
|
||||
})
|
||||
|
||||
result.current.handleSelectionChange({
|
||||
nodes: [],
|
||||
edges: [{ id: 'e1' }],
|
||||
} as unknown as OnSelectionChangeParams)
|
||||
act(() => {
|
||||
result.current.handleSelectionChange({
|
||||
nodes: [],
|
||||
edges: [{ id: 'e1' }],
|
||||
} as unknown as OnSelectionChangeParams)
|
||||
})
|
||||
|
||||
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
|
||||
expect(updatedEdges.find(e => e.id === 'e1')!.data._isBundled).toBe(true)
|
||||
expect(updatedEdges.find(e => e.id === 'e2')!.data._isBundled).toBe(false)
|
||||
await waitFor(() => {
|
||||
expect(getBundledState(result.current.edges.find(edge => edge.id === 'e1'))._isBundled).toBe(true)
|
||||
expect(getBundledState(result.current.edges.find(edge => edge.id === 'e2'))._isBundled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('handleSelectionDrag should sync node positions', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useSelectionInteractions())
|
||||
|
||||
it('handleSelectionDrag should sync node positions', async () => {
|
||||
const { result, store } = renderSelectionInteractions()
|
||||
const draggedNodes = [
|
||||
{ id: 'n1', position: { x: 50, y: 60 }, data: {} },
|
||||
] as unknown as Node[]
|
||||
] as never
|
||||
|
||||
result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes)
|
||||
act(() => {
|
||||
result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes)
|
||||
})
|
||||
|
||||
expect(store.getState().nodeAnimation).toBe(false)
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
|
||||
expect(updatedNodes.find(n => n.id === 'n1')!.position).toEqual({ x: 50, y: 60 })
|
||||
expect(updatedNodes.find(n => n.id === 'n2')!.position).toEqual({ x: 100, y: 100 })
|
||||
await waitFor(() => {
|
||||
expect(result.current.nodes.find(node => node.id === 'n1')?.position).toEqual({ x: 50, y: 60 })
|
||||
expect(result.current.nodes.find(node => node.id === 'n2')?.position).toEqual({ x: 100, y: 100 })
|
||||
})
|
||||
})
|
||||
|
||||
it('handleSelectionCancel should clear all selection state', () => {
|
||||
const { result } = renderWorkflowHook(() => useSelectionInteractions())
|
||||
it('handleSelectionCancel should clear all selection state', async () => {
|
||||
const { result } = renderSelectionInteractions()
|
||||
|
||||
result.current.handleSelectionCancel()
|
||||
|
||||
expect(rfStoreExtra.setState).toHaveBeenCalledWith({
|
||||
userSelectionRect: null,
|
||||
userSelectionActive: true,
|
||||
act(() => {
|
||||
result.current.reactFlowStore.setState({
|
||||
userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
|
||||
userSelectionActive: false,
|
||||
} as never)
|
||||
})
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
|
||||
expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
|
||||
act(() => {
|
||||
result.current.handleSelectionCancel()
|
||||
})
|
||||
|
||||
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
|
||||
expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
|
||||
expect(result.current.reactFlowStore.getState().userSelectionRect).toBeNull()
|
||||
expect(result.current.reactFlowStore.getState().userSelectionActive).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true)
|
||||
expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
|
||||
initialStoreState: {
|
||||
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
|
||||
panelMenu: { top: 30, left: 40 },
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
},
|
||||
const { result, store } = renderSelectionInteractions({
|
||||
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
|
||||
panelMenu: { top: 30, left: 40 },
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
})
|
||||
|
||||
const wrongTarget = document.createElement('div')
|
||||
wrongTarget.classList.add('some-other-class')
|
||||
result.current.handleSelectionContextMenu({
|
||||
target: wrongTarget,
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
} as unknown as React.MouseEvent)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSelectionContextMenu({
|
||||
target: wrongTarget,
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
} as unknown as React.MouseEvent)
|
||||
})
|
||||
|
||||
expect(store.getState().selectionMenu).toBeUndefined()
|
||||
|
||||
const correctTarget = document.createElement('div')
|
||||
correctTarget.classList.add('react-flow__nodesselection-rect')
|
||||
result.current.handleSelectionContextMenu({
|
||||
target: correctTarget,
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
} as unknown as React.MouseEvent)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSelectionContextMenu({
|
||||
target: correctTarget,
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
} as unknown as React.MouseEvent)
|
||||
})
|
||||
|
||||
expect(store.getState().selectionMenu).toEqual({
|
||||
top: 150,
|
||||
@ -188,11 +209,13 @@ describe('useSelectionInteractions', () => {
|
||||
})
|
||||
|
||||
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
|
||||
initialStoreState: { selectionMenu: { top: 50, left: 60 } },
|
||||
const { result, store } = renderSelectionInteractions({
|
||||
selectionMenu: { top: 50, left: 60 },
|
||||
})
|
||||
|
||||
result.current.handleSelectionContextmenuCancel()
|
||||
act(() => {
|
||||
result.current.handleSelectionContextmenuCancel()
|
||||
})
|
||||
|
||||
expect(store.getState().selectionMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
@ -1,130 +1,209 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useEdges, useNodes } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus } from '../../types'
|
||||
import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync'
|
||||
import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
type EdgeRuntimeState = {
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
type NodeRuntimeState = {
|
||||
_runningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
describe('useEdgesInteractionsWithoutSync', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'a', target: 'b', data: { _sourceRunningStatus: 'running', _targetRunningStatus: 'running', _waitingRun: true } },
|
||||
{ id: 'e2', source: 'b', target: 'c', data: { _sourceRunningStatus: 'succeeded', _targetRunningStatus: undefined, _waitingRun: false } },
|
||||
]
|
||||
})
|
||||
const createFlowNodes = () => [
|
||||
createNode({ id: 'a' }),
|
||||
createNode({ id: 'b' }),
|
||||
createNode({ id: 'c' }),
|
||||
]
|
||||
const createFlowEdges = () => [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
data: {
|
||||
_sourceRunningStatus: NodeRunningStatus.Running,
|
||||
_targetRunningStatus: NodeRunningStatus.Running,
|
||||
_waitingRun: true,
|
||||
},
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'b',
|
||||
target: 'c',
|
||||
data: {
|
||||
_sourceRunningStatus: NodeRunningStatus.Succeeded,
|
||||
_targetRunningStatus: undefined,
|
||||
_waitingRun: false,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const renderEdgesInteractionsHook = () =>
|
||||
renderWorkflowFlowHook(() => ({
|
||||
...useEdgesInteractionsWithoutSync(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges: createFlowEdges(),
|
||||
})
|
||||
|
||||
it('should clear running status and waitingRun on all edges', () => {
|
||||
const { result } = renderHook(() => useEdgesInteractionsWithoutSync())
|
||||
const { result } = renderEdgesInteractionsHook()
|
||||
|
||||
result.current.handleEdgeCancelRunningStatus()
|
||||
act(() => {
|
||||
result.current.handleEdgeCancelRunningStatus()
|
||||
})
|
||||
|
||||
expect(rfState.setEdges).toHaveBeenCalledOnce()
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
for (const edge of updated) {
|
||||
expect(edge.data._sourceRunningStatus).toBeUndefined()
|
||||
expect(edge.data._targetRunningStatus).toBeUndefined()
|
||||
expect(edge.data._waitingRun).toBe(false)
|
||||
}
|
||||
return waitFor(() => {
|
||||
result.current.edges.forEach((edge) => {
|
||||
const edgeState = getEdgeRuntimeState(edge)
|
||||
expect(edgeState._sourceRunningStatus).toBeUndefined()
|
||||
expect(edgeState._targetRunningStatus).toBeUndefined()
|
||||
expect(edgeState._waitingRun).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not mutate original edges', () => {
|
||||
const originalData = { ...rfState.edges[0].data }
|
||||
const { result } = renderHook(() => useEdgesInteractionsWithoutSync())
|
||||
const edges = createFlowEdges()
|
||||
const originalData = { ...getEdgeRuntimeState(edges[0]) }
|
||||
const { result } = renderWorkflowFlowHook(() => ({
|
||||
...useEdgesInteractionsWithoutSync(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges,
|
||||
})
|
||||
|
||||
result.current.handleEdgeCancelRunningStatus()
|
||||
act(() => {
|
||||
result.current.handleEdgeCancelRunningStatus()
|
||||
})
|
||||
|
||||
expect(rfState.edges[0].data._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
|
||||
expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNodesInteractionsWithoutSync', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } },
|
||||
{ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } },
|
||||
{ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } },
|
||||
]
|
||||
})
|
||||
const createFlowNodes = () => [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }),
|
||||
createNode({ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }),
|
||||
createNode({ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }),
|
||||
]
|
||||
|
||||
const renderNodesInteractionsHook = () =>
|
||||
renderWorkflowFlowHook(() => ({
|
||||
...useNodesInteractionsWithoutSync(),
|
||||
nodes: useNodes(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges: [],
|
||||
})
|
||||
|
||||
describe('handleNodeCancelRunningStatus', () => {
|
||||
it('should clear _runningStatus and _waitingRun on all nodes', () => {
|
||||
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
|
||||
it('should clear _runningStatus and _waitingRun on all nodes', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
result.current.handleNodeCancelRunningStatus()
|
||||
act(() => {
|
||||
result.current.handleNodeCancelRunningStatus()
|
||||
})
|
||||
|
||||
expect(rfState.setNodes).toHaveBeenCalledOnce()
|
||||
const updated = rfState.setNodes.mock.calls[0][0]
|
||||
for (const node of updated) {
|
||||
expect(node.data._runningStatus).toBeUndefined()
|
||||
expect(node.data._waitingRun).toBe(false)
|
||||
}
|
||||
await waitFor(() => {
|
||||
result.current.nodes.forEach((node) => {
|
||||
const nodeState = getNodeRuntimeState(node)
|
||||
expect(nodeState._runningStatus).toBeUndefined()
|
||||
expect(nodeState._waitingRun).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCancelAllNodeSuccessStatus', () => {
|
||||
it('should clear _runningStatus only for Succeeded nodes', () => {
|
||||
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
|
||||
it('should clear _runningStatus only for Succeeded nodes', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
result.current.handleCancelAllNodeSuccessStatus()
|
||||
act(() => {
|
||||
result.current.handleCancelAllNodeSuccessStatus()
|
||||
})
|
||||
|
||||
expect(rfState.setNodes).toHaveBeenCalledOnce()
|
||||
const updated = rfState.setNodes.mock.calls[0][0]
|
||||
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
|
||||
const n2 = updated.find((n: { id: string }) => n.id === 'n2')
|
||||
const n3 = updated.find((n: { id: string }) => n.id === 'n3')
|
||||
await waitFor(() => {
|
||||
const n1 = result.current.nodes.find(node => node.id === 'n1')
|
||||
const n2 = result.current.nodes.find(node => node.id === 'n2')
|
||||
const n3 = result.current.nodes.find(node => node.id === 'n3')
|
||||
|
||||
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(n2.data._runningStatus).toBeUndefined()
|
||||
expect(n3.data._runningStatus).toBe(NodeRunningStatus.Failed)
|
||||
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
|
||||
expect(getNodeRuntimeState(n3)._runningStatus).toBe(NodeRunningStatus.Failed)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not modify _waitingRun', () => {
|
||||
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
|
||||
it('should not modify _waitingRun', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
result.current.handleCancelAllNodeSuccessStatus()
|
||||
act(() => {
|
||||
result.current.handleCancelAllNodeSuccessStatus()
|
||||
})
|
||||
|
||||
const updated = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updated.find((n: { id: string }) => n.id === 'n1').data._waitingRun).toBe(true)
|
||||
expect(updated.find((n: { id: string }) => n.id === 'n3').data._waitingRun).toBe(true)
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._waitingRun).toBe(true)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n3'))._waitingRun).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCancelNodeSuccessStatus', () => {
|
||||
it('should clear _runningStatus and _waitingRun for the specified Succeeded node', () => {
|
||||
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
|
||||
it('should clear _runningStatus and _waitingRun for the specified Succeeded node', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
result.current.handleCancelNodeSuccessStatus('n2')
|
||||
act(() => {
|
||||
result.current.handleCancelNodeSuccessStatus('n2')
|
||||
})
|
||||
|
||||
expect(rfState.setNodes).toHaveBeenCalledOnce()
|
||||
const updated = rfState.setNodes.mock.calls[0][0]
|
||||
const n2 = updated.find((n: { id: string }) => n.id === 'n2')
|
||||
expect(n2.data._runningStatus).toBeUndefined()
|
||||
expect(n2.data._waitingRun).toBe(false)
|
||||
await waitFor(() => {
|
||||
const n2 = result.current.nodes.find(node => node.id === 'n2')
|
||||
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
|
||||
expect(getNodeRuntimeState(n2)._waitingRun).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not modify nodes that are not Succeeded', () => {
|
||||
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
|
||||
it('should not modify nodes that are not Succeeded', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
result.current.handleCancelNodeSuccessStatus('n1')
|
||||
act(() => {
|
||||
result.current.handleCancelNodeSuccessStatus('n1')
|
||||
})
|
||||
|
||||
const updated = rfState.setNodes.mock.calls[0][0]
|
||||
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
|
||||
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(n1.data._waitingRun).toBe(true)
|
||||
await waitFor(() => {
|
||||
const n1 = result.current.nodes.find(node => node.id === 'n1')
|
||||
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(n1)._waitingRun).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not modify other nodes', () => {
|
||||
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
|
||||
it('should not modify other nodes', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
result.current.handleCancelNodeSuccessStatus('n2')
|
||||
act(() => {
|
||||
result.current.handleCancelNodeSuccessStatus('n2')
|
||||
})
|
||||
|
||||
const updated = rfState.setNodes.mock.calls[0][0]
|
||||
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
|
||||
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
await waitFor(() => {
|
||||
const n1 = result.current.nodes.find(node => node.id === 'n1')
|
||||
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,8 +7,10 @@ import type {
|
||||
NodeFinishedResponse,
|
||||
WorkflowStartedResponse,
|
||||
} from '@/types/workflow'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useEdges, useNodes } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { DEFAULT_ITER_TIMES } from '../../constants'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
|
||||
import { useWorkflowNodeFinished } from '../use-workflow-run-event/use-workflow-node-finished'
|
||||
@ -19,44 +21,100 @@ import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-
|
||||
import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry'
|
||||
import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
type NodeRuntimeState = {
|
||||
_waitingRun?: boolean
|
||||
_runningStatus?: NodeRunningStatus
|
||||
_retryIndex?: number
|
||||
_iterationIndex?: number
|
||||
_loopIndex?: number
|
||||
_runningBranchId?: string
|
||||
}
|
||||
|
||||
type EdgeRuntimeState = {
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
function createRunNodes() {
|
||||
return [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
width: 200,
|
||||
height: 80,
|
||||
data: { _waitingRun: false },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function createRunEdges() {
|
||||
return [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n0',
|
||||
target: 'n1',
|
||||
data: {},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function renderRunEventHook<T extends Record<string, unknown>>(
|
||||
useHook: () => T,
|
||||
options?: {
|
||||
nodes?: ReturnType<typeof createRunNodes>
|
||||
edges?: ReturnType<typeof createRunEdges>
|
||||
initialStoreState?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
const { nodes = createRunNodes(), edges = createRunEdges(), initialStoreState } = options ?? {}
|
||||
|
||||
return renderWorkflowFlowHook(() => ({
|
||||
...useHook(),
|
||||
nodes: useNodes(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps: { fitView: false },
|
||||
initialStoreState,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useWorkflowStarted', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _waitingRun: false } },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
|
||||
]
|
||||
})
|
||||
|
||||
it('should initialize workflow running data and reset nodes/edges', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
|
||||
it('should initialize workflow running data and reset nodes/edges', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowStarted({
|
||||
task_id: 'task-2',
|
||||
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
|
||||
} as WorkflowStartedResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowStarted({
|
||||
task_id: 'task-2',
|
||||
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
|
||||
} as WorkflowStartedResponse)
|
||||
})
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.task_id).toBe('task-2')
|
||||
expect(state.result.status).toBe(WorkflowRunningStatus.Running)
|
||||
expect(state.resultText).toBe('')
|
||||
|
||||
expect(rfState.setNodes).toHaveBeenCalledOnce()
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updatedNodes[0].data._waitingRun).toBe(true)
|
||||
|
||||
expect(rfState.setEdges).toHaveBeenCalledOnce()
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(true)
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBeUndefined()
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._sourceRunningStatus).toBeUndefined()
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBeUndefined()
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should resume from Paused without resetting nodes/edges', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'],
|
||||
@ -64,30 +122,28 @@ describe('useWorkflowStarted', () => {
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowStarted({
|
||||
task_id: 'task-2',
|
||||
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
|
||||
} as WorkflowStartedResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowStarted({
|
||||
task_id: 'task-2',
|
||||
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
|
||||
} as WorkflowStartedResponse)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running)
|
||||
expect(rfState.setNodes).not.toHaveBeenCalled()
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeFinished', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
|
||||
]
|
||||
})
|
||||
|
||||
it('should update tracing and node running status', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
|
||||
it('should update tracing and node running status', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
@ -95,20 +151,29 @@ describe('useWorkflowNodeFinished', () => {
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeFinished({
|
||||
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
} as NodeFinishedResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeFinished({
|
||||
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
} as NodeFinishedResponse)
|
||||
})
|
||||
|
||||
const trace = store.getState().workflowRunningData!.tracing![0]
|
||||
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(rfState.setEdges).toHaveBeenCalledOnce()
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set _runningBranchId for IfElse node', () => {
|
||||
const { result } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
|
||||
it('should set _runningBranchId for IfElse node', async () => {
|
||||
const { result } = renderRunEventHook(() => useWorkflowNodeFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
@ -116,83 +181,75 @@ describe('useWorkflowNodeFinished', () => {
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeFinished({
|
||||
data: {
|
||||
id: 'trace-1',
|
||||
node_id: 'n1',
|
||||
node_type: 'if-else',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
outputs: { selected_case_id: 'branch-a' },
|
||||
},
|
||||
} as unknown as NodeFinishedResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeFinished({
|
||||
data: {
|
||||
id: 'trace-1',
|
||||
node_id: 'n1',
|
||||
node_type: 'if-else',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
outputs: { selected_case_id: 'branch-a' },
|
||||
},
|
||||
} as unknown as NodeFinishedResponse)
|
||||
})
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updatedNodes[0].data._runningBranchId).toBe('branch-a')
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBe('branch-a')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeRetry', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
|
||||
]
|
||||
})
|
||||
|
||||
it('should push retry data to tracing and update _retryIndex', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeRetry(), {
|
||||
it('should push retry data to tracing and update _retryIndex', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeRetry(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeRetry({
|
||||
data: { node_id: 'n1', retry_index: 2 },
|
||||
} as NodeFinishedResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeRetry({
|
||||
data: { node_id: 'n1', retry_index: 2 },
|
||||
} as NodeFinishedResponse)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updatedNodes[0].data._retryIndex).toBe(2)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._retryIndex).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeIterationNext', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
|
||||
]
|
||||
})
|
||||
|
||||
it('should set _iterationIndex and increment iterTimes', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationNext(), {
|
||||
it('should set _iterationIndex and increment iterTimes', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationNext(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData(),
|
||||
iterTimes: 3,
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeIterationNext({
|
||||
data: { node_id: 'n1' },
|
||||
} as IterationNextResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeIterationNext({
|
||||
data: { node_id: 'n1' },
|
||||
} as IterationNextResponse)
|
||||
})
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updatedNodes[0].data._iterationIndex).toBe(3)
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._iterationIndex).toBe(3)
|
||||
})
|
||||
expect(store.getState().iterTimes).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeIterationFinished', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
|
||||
]
|
||||
})
|
||||
|
||||
it('should update tracing, reset iterTimes, update node status and edges', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationFinished(), {
|
||||
it('should update tracing, reset iterTimes, update node status and edges', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
@ -201,56 +258,60 @@ describe('useWorkflowNodeIterationFinished', () => {
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeIterationFinished({
|
||||
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
} as IterationFinishedResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeIterationFinished({
|
||||
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
} as IterationFinishedResponse)
|
||||
})
|
||||
|
||||
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(rfState.setEdges).toHaveBeenCalledOnce()
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeLoopNext', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: 'n2', position: { x: 300, y: 0 }, parentId: 'n1', data: { _waitingRun: false } },
|
||||
]
|
||||
})
|
||||
|
||||
it('should set _loopIndex and reset child nodes to waiting', () => {
|
||||
const { result } = renderWorkflowHook(() => useWorkflowNodeLoopNext(), {
|
||||
it('should set _loopIndex and reset child nodes to waiting', async () => {
|
||||
const { result } = renderRunEventHook(() => useWorkflowNodeLoopNext(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: {} }),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 300, y: 0 },
|
||||
parentId: 'n1',
|
||||
data: { _waitingRun: false },
|
||||
}),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeLoopNext({
|
||||
data: { node_id: 'n1', index: 5 },
|
||||
} as LoopNextResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeLoopNext({
|
||||
data: { node_id: 'n1', index: 5 },
|
||||
} as LoopNextResponse)
|
||||
})
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updatedNodes[0].data._loopIndex).toBe(5)
|
||||
expect(updatedNodes[1].data._waitingRun).toBe(true)
|
||||
expect(updatedNodes[1].data._runningStatus).toBe(NodeRunningStatus.Waiting)
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._loopIndex).toBe(5)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._waitingRun).toBe(true)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Waiting)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeLoopFinished', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
|
||||
]
|
||||
})
|
||||
|
||||
it('should update tracing, node status and edges', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopFinished(), {
|
||||
it('should update tracing, node status and edges', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeLoopFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
@ -258,12 +319,18 @@ describe('useWorkflowNodeLoopFinished', () => {
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeLoopFinished({
|
||||
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
} as LoopFinishedResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeLoopFinished({
|
||||
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
} as LoopFinishedResponse)
|
||||
})
|
||||
|
||||
const trace = store.getState().workflowRunningData!.tracing![0]
|
||||
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(rfState.setEdges).toHaveBeenCalledOnce()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,8 +4,10 @@ import type {
|
||||
LoopStartedResponse,
|
||||
NodeStartedResponse,
|
||||
} from '@/types/workflow'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useEdges, useNodes, useStoreApi } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { DEFAULT_ITER_TIMES } from '../../constants'
|
||||
import { NodeRunningStatus } from '../../types'
|
||||
import { useWorkflowNodeHumanInputRequired } from '../use-workflow-run-event/use-workflow-node-human-input-required'
|
||||
@ -13,67 +15,145 @@ import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-w
|
||||
import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started'
|
||||
import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
function findNodeById(nodes: Array<{ id: string, data: Record<string, unknown> }>, id: string) {
|
||||
return nodes.find(n => n.id === id)!
|
||||
type NodeRuntimeState = {
|
||||
_waitingRun?: boolean
|
||||
_runningStatus?: NodeRunningStatus
|
||||
_iterationLength?: number
|
||||
_loopLength?: number
|
||||
}
|
||||
|
||||
type EdgeRuntimeState = {
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
const containerParams = { clientWidth: 1200, clientHeight: 800 }
|
||||
|
||||
describe('useWorkflowNodeStarted', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
|
||||
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
|
||||
{ id: 'n2', position: { x: 400, y: 50 }, width: 200, height: 80, parentId: 'n1', data: { _waitingRun: true } },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
|
||||
]
|
||||
})
|
||||
function createViewportNodes() {
|
||||
return [
|
||||
createNode({
|
||||
id: 'n0',
|
||||
width: 200,
|
||||
height: 80,
|
||||
data: { _runningStatus: NodeRunningStatus.Succeeded },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n1',
|
||||
position: { x: 100, y: 50 },
|
||||
width: 200,
|
||||
height: 80,
|
||||
data: { _waitingRun: true },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 400, y: 50 },
|
||||
width: 200,
|
||||
height: 80,
|
||||
parentId: 'n1',
|
||||
data: { _waitingRun: true },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
it('should push to tracing, set node running, and adjust viewport for root node', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
|
||||
function createViewportEdges() {
|
||||
return [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n0',
|
||||
target: 'n1',
|
||||
sourceHandle: 'source',
|
||||
data: {},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function renderViewportHook<T extends Record<string, unknown>>(
|
||||
useHook: () => T,
|
||||
options?: {
|
||||
nodes?: ReturnType<typeof createViewportNodes>
|
||||
edges?: ReturnType<typeof createViewportEdges>
|
||||
initialStoreState?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
const {
|
||||
nodes = createViewportNodes(),
|
||||
edges = createViewportEdges(),
|
||||
initialStoreState,
|
||||
} = options ?? {}
|
||||
|
||||
return renderWorkflowFlowHook(() => ({
|
||||
...useHook(),
|
||||
nodes: useNodes(),
|
||||
edges: useEdges(),
|
||||
reactFlowStore: useStoreApi(),
|
||||
}), {
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps: { fitView: false },
|
||||
initialStoreState,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useWorkflowNodeStarted', () => {
|
||||
it('should push to tracing, set node running, and adjust viewport for root node', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing).toHaveLength(1)
|
||||
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
|
||||
|
||||
expect(rfState.setViewport).toHaveBeenCalledOnce()
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(200)
|
||||
expect(transform[1]).toBe(310)
|
||||
expect(transform[2]).toBe(1)
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
const n1 = findNodeById(updatedNodes, 'n1')
|
||||
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(n1.data._waitingRun).toBe(false)
|
||||
|
||||
expect(rfState.setEdges).toHaveBeenCalledOnce()
|
||||
const node = result.current.nodes.find(item => item.id === 'n1')
|
||||
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not adjust viewport for child node (has parentId)', () => {
|
||||
const { result } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
|
||||
it('should not adjust viewport for child node (has parentId)', async () => {
|
||||
const { result } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n2' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n2' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
expect(rfState.setViewport).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(0)
|
||||
expect(transform[1]).toBe(0)
|
||||
expect(transform[2]).toBe(1)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update existing tracing entry if node_id exists at non-zero index', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [
|
||||
@ -84,10 +164,12 @@ describe('useWorkflowNodeStarted', () => {
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing).toHaveLength(2)
|
||||
@ -96,92 +178,80 @@ describe('useWorkflowNodeStarted', () => {
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeIterationStarted', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
|
||||
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
|
||||
]
|
||||
})
|
||||
|
||||
it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationStarted(), {
|
||||
it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeIterationStarted(), {
|
||||
nodes: createViewportNodes().slice(0, 2),
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData(),
|
||||
iterTimes: 99,
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeIterationStarted(
|
||||
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeIterationStarted(
|
||||
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
|
||||
|
||||
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
|
||||
expect(rfState.setViewport).toHaveBeenCalledOnce()
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
const n1 = findNodeById(updatedNodes, 'n1')
|
||||
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(n1.data._iterationLength).toBe(10)
|
||||
expect(n1.data._waitingRun).toBe(false)
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(200)
|
||||
expect(transform[1]).toBe(310)
|
||||
expect(transform[2]).toBe(1)
|
||||
|
||||
expect(rfState.setEdges).toHaveBeenCalledOnce()
|
||||
const node = result.current.nodes.find(item => item.id === 'n1')
|
||||
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(node)._iterationLength).toBe(10)
|
||||
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeLoopStarted', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
|
||||
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
|
||||
]
|
||||
})
|
||||
|
||||
it('should push to tracing, set viewport, and update node with _loopLength', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopStarted(), {
|
||||
it('should push to tracing, set viewport, and update node with _loopLength', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeLoopStarted(), {
|
||||
nodes: createViewportNodes().slice(0, 2),
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeLoopStarted(
|
||||
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeLoopStarted(
|
||||
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running)
|
||||
expect(rfState.setViewport).toHaveBeenCalledOnce()
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
const n1 = findNodeById(updatedNodes, 'n1')
|
||||
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(n1.data._loopLength).toBe(5)
|
||||
expect(n1.data._waitingRun).toBe(false)
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(200)
|
||||
expect(transform[1]).toBe(310)
|
||||
expect(transform[2]).toBe(1)
|
||||
|
||||
expect(rfState.setEdges).toHaveBeenCalledOnce()
|
||||
const node = result.current.nodes.find(item => item.id === 'n1')
|
||||
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(node)._loopLength).toBe(5)
|
||||
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeHumanInputRequired', () => {
|
||||
beforeEach(() => {
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
|
||||
{ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
|
||||
]
|
||||
})
|
||||
|
||||
it('should create humanInputFormDataList and set tracing/node to Paused', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
it('should create humanInputFormDataList and set tracing/node to Paused', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
@ -189,21 +259,29 @@ describe('useWorkflowNodeHumanInputRequired', () => {
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
|
||||
} as HumanInputRequiredResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
|
||||
} as HumanInputRequiredResponse)
|
||||
})
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.humanInputFormDataList).toHaveLength(1)
|
||||
expect(state.humanInputFormDataList![0].form_id).toBe('f1')
|
||||
expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused)
|
||||
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(findNodeById(updatedNodes, 'n1').data._runningStatus).toBe(NodeRunningStatus.Paused)
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n1'))._runningStatus).toBe(NodeRunningStatus.Paused)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update existing form entry for same node_id', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
@ -214,9 +292,11 @@ describe('useWorkflowNodeHumanInputRequired', () => {
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
|
||||
} as HumanInputRequiredResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
|
||||
} as HumanInputRequiredResponse)
|
||||
})
|
||||
|
||||
const formList = store.getState().workflowRunningData!.humanInputFormDataList!
|
||||
expect(formList).toHaveLength(1)
|
||||
@ -224,7 +304,12 @@ describe('useWorkflowNodeHumanInputRequired', () => {
|
||||
})
|
||||
|
||||
it('should append new form entry for different node_id', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }],
|
||||
@ -235,9 +320,11 @@ describe('useWorkflowNodeHumanInputRequired', () => {
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
|
||||
} as HumanInputRequiredResponse)
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
|
||||
} as HumanInputRequiredResponse)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { baseRunningData, renderWorkflowFlowHook, renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowRunningStatus } from '../../types'
|
||||
import {
|
||||
useIsChatMode,
|
||||
@ -10,9 +10,6 @@ import {
|
||||
useWorkflowReadOnly,
|
||||
} from '../use-workflow'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
let mockAppMode = 'workflow'
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail: { mode: string } }) => unknown) => selector({ appDetail: { mode: mockAppMode } }),
|
||||
@ -20,7 +17,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
mockAppMode = 'workflow'
|
||||
})
|
||||
|
||||
@ -158,37 +154,50 @@ describe('useNodesReadOnly', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useIsNodeInIteration', () => {
|
||||
beforeEach(() => {
|
||||
rfState.nodes = [
|
||||
{ id: 'iter-1', position: { x: 0, y: 0 }, data: { type: 'iteration' } },
|
||||
{ id: 'child-1', position: { x: 10, y: 0 }, parentId: 'iter-1', data: {} },
|
||||
{ id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
|
||||
{ id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
|
||||
]
|
||||
})
|
||||
const createIterationNodes = () => [
|
||||
createNode({ id: 'iter-1' }),
|
||||
createNode({ id: 'child-1', parentId: 'iter-1' }),
|
||||
createNode({ id: 'grandchild-1', parentId: 'child-1' }),
|
||||
createNode({ id: 'outside-1' }),
|
||||
]
|
||||
|
||||
it('should return true when node is a direct child of the iteration', () => {
|
||||
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
|
||||
nodes: createIterationNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInIteration('child-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for a grandchild (only checks direct parentId)', () => {
|
||||
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
|
||||
nodes: createIterationNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInIteration('grandchild-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when node is outside the iteration', () => {
|
||||
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
|
||||
nodes: createIterationNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInIteration('outside-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when node does not exist', () => {
|
||||
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
|
||||
nodes: createIterationNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInIteration('nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when iteration id has no children', () => {
|
||||
const { result } = renderHook(() => useIsNodeInIteration('no-such-iter'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('no-such-iter'), {
|
||||
nodes: createIterationNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInIteration('child-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -198,37 +207,50 @@ describe('useIsNodeInIteration', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useIsNodeInLoop', () => {
|
||||
beforeEach(() => {
|
||||
rfState.nodes = [
|
||||
{ id: 'loop-1', position: { x: 0, y: 0 }, data: { type: 'loop' } },
|
||||
{ id: 'child-1', position: { x: 10, y: 0 }, parentId: 'loop-1', data: {} },
|
||||
{ id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
|
||||
{ id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
|
||||
]
|
||||
})
|
||||
const createLoopNodes = () => [
|
||||
createNode({ id: 'loop-1' }),
|
||||
createNode({ id: 'child-1', parentId: 'loop-1' }),
|
||||
createNode({ id: 'grandchild-1', parentId: 'child-1' }),
|
||||
createNode({ id: 'outside-1' }),
|
||||
]
|
||||
|
||||
it('should return true when node is a direct child of the loop', () => {
|
||||
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
|
||||
nodes: createLoopNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInLoop('child-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for a grandchild (only checks direct parentId)', () => {
|
||||
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
|
||||
nodes: createLoopNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInLoop('grandchild-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when node is outside the loop', () => {
|
||||
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
|
||||
nodes: createLoopNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInLoop('outside-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when node does not exist', () => {
|
||||
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
|
||||
nodes: createLoopNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInLoop('nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when loop id has no children', () => {
|
||||
const { result } = renderHook(() => useIsNodeInLoop('no-such-loop'))
|
||||
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('no-such-loop'), {
|
||||
nodes: createLoopNodes(),
|
||||
edges: [],
|
||||
})
|
||||
expect(result.current.isNodeInLoop('child-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,16 +6,18 @@ import type {
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { createDocLinkMock } from '../../../../__tests__/i18n'
|
||||
import { AgentStrategy } from '../agent-strategy'
|
||||
|
||||
const createI18nLabel = (text: string) => ({ en_US: text, zh_Hans: text })
|
||||
const mockDocLink = createDocLinkMock('/docs')
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: () => ({ data: null }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => () => '/docs',
|
||||
useDocLink: () => mockDocLink,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Field from './field'
|
||||
import Field from '../field'
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
|
||||
@ -1,8 +1,8 @@
|
||||
import type { CommonNodeType } from '../../../types'
|
||||
import type { CommonNodeType } from '../../../../types'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '../../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../../types'
|
||||
import NodeControl from './node-control'
|
||||
import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../../../types'
|
||||
import NodeControl from '../node-control'
|
||||
|
||||
const {
|
||||
mockHandleNodeSelect,
|
||||
@ -14,8 +14,8 @@ const {
|
||||
|
||||
let mockPluginInstallLocked = false
|
||||
|
||||
vi.mock('../../../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../hooks')>('../../../hooks')
|
||||
vi.mock('../../../../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../../hooks')>('../../../../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useNodesInteractions: () => ({
|
||||
@ -24,15 +24,15 @@ vi.mock('../../../hooks', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils')>('../../../utils')
|
||||
vi.mock('../../../../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../../utils')>('../../../../utils')
|
||||
return {
|
||||
...actual,
|
||||
canRunBySingle: mockCanRunBySingle,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./panel-operator', () => ({
|
||||
vi.mock('../panel-operator', () => ({
|
||||
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
|
||||
<>
|
||||
<button type="button" onClick={() => onOpenChange(true)}>open panel</button>
|
||||
@ -0,0 +1,83 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Collapse from '../index'
|
||||
|
||||
describe('Collapse', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Collapse should toggle local state when interactive and stay fixed when disabled.
|
||||
describe('Interaction', () => {
|
||||
it('should expand collapsed content and notify onCollapse when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapse = vi.fn()
|
||||
|
||||
render(
|
||||
<Collapse
|
||||
trigger={<div>Advanced</div>}
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div>Collapse content</div>
|
||||
</Collapse>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Collapse content')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Advanced'))
|
||||
|
||||
expect(screen.getByText('Collapse content')).toBeInTheDocument()
|
||||
expect(onCollapse).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should keep content collapsed when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapse = vi.fn()
|
||||
|
||||
render(
|
||||
<Collapse
|
||||
disabled
|
||||
trigger={<div>Disabled section</div>}
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div>Hidden content</div>
|
||||
</Collapse>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Disabled section'))
|
||||
|
||||
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
|
||||
expect(onCollapse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should respect controlled collapse state and render function triggers', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapse = vi.fn()
|
||||
|
||||
render(
|
||||
<Collapse
|
||||
collapsed={false}
|
||||
hideCollapseIcon
|
||||
operations={<button type="button">Operation</button>}
|
||||
trigger={collapseIcon => (
|
||||
<div>
|
||||
<span>Controlled section</span>
|
||||
{collapseIcon}
|
||||
</div>
|
||||
)}
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div>Visible content</div>
|
||||
</Collapse>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Visible content')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Operation' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Controlled section'))
|
||||
|
||||
expect(onCollapse).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByText('Visible content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import InputField from '../index'
|
||||
|
||||
describe('InputField', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The placeholder field should render its title, body, and add action.
|
||||
describe('Rendering', () => {
|
||||
it('should render the default field title and content', () => {
|
||||
render(<InputField />)
|
||||
|
||||
expect(screen.getAllByText('input field')).toHaveLength(2)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FieldTitle } from './field-title'
|
||||
import { FieldTitle } from '../field-title'
|
||||
|
||||
vi.mock('@/app/components/base/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BoxGroupField, FieldTitle } from '../index'
|
||||
|
||||
describe('layout index', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The barrel exports should compose the public layout primitives without extra wrappers.
|
||||
describe('Rendering', () => {
|
||||
it('should render BoxGroupField from the barrel export', () => {
|
||||
render(
|
||||
<BoxGroupField
|
||||
fieldProps={{
|
||||
fieldTitleProps: {
|
||||
title: 'Input',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Body content
|
||||
</BoxGroupField>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Input')).toBeInTheDocument()
|
||||
expect(screen.getByText('Body content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FieldTitle from the barrel export', () => {
|
||||
render(<FieldTitle title="Advanced" subTitle="Extra details" />)
|
||||
|
||||
expect(screen.getByText('Advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extra details')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,195 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import {
|
||||
createEdge,
|
||||
createNode,
|
||||
} from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useToolIcon,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import NextStep from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({ trigger }: { trigger: ((open: boolean) => ReactNode) | ReactNode }) => {
|
||||
return (
|
||||
<div data-testid="next-step-block-selector">
|
||||
{typeof trigger === 'function' ? trigger(false) : trigger}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useToolIcon: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseToolIcon = vi.mocked(useToolIcon)
|
||||
|
||||
const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => ({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})
|
||||
|
||||
const renderComponent = (selectedNode: Node, nodes: Node[], edges: Edge[] = []) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<NextStep selectedNode={selectedNode} />,
|
||||
{
|
||||
nodes,
|
||||
edges,
|
||||
canvasStyle: {
|
||||
width: 600,
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
describe('NextStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult())
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeSelect: vi.fn(),
|
||||
handleNodeAdd: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
nodesReadOnly: true,
|
||||
} as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseToolIcon.mockReturnValue('')
|
||||
})
|
||||
|
||||
// NextStep should summarize linear next nodes and failure branches from the real ReactFlow graph.
|
||||
describe('Rendering', () => {
|
||||
it('should render connected next nodes and the parallel add action for the default source handle', () => {
|
||||
const selectedNode = createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Selected Node',
|
||||
},
|
||||
})
|
||||
const nextNode = createNode({
|
||||
id: 'next-node',
|
||||
data: {
|
||||
type: BlockEnum.Answer,
|
||||
title: 'Next Node',
|
||||
},
|
||||
})
|
||||
const edge = createEdge({
|
||||
source: 'selected-node',
|
||||
target: 'next-node',
|
||||
sourceHandle: 'source',
|
||||
})
|
||||
|
||||
renderComponent(selectedNode, [selectedNode, nextNode], [edge])
|
||||
|
||||
expect(screen.getByText('Next Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.addParallelNode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configured branch names when target branches are present', () => {
|
||||
const selectedNode = createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Selected Node',
|
||||
_targetBranches: [{
|
||||
id: 'branch-a',
|
||||
name: 'Approved',
|
||||
}],
|
||||
},
|
||||
})
|
||||
const nextNode = createNode({
|
||||
id: 'next-node',
|
||||
data: {
|
||||
type: BlockEnum.Answer,
|
||||
title: 'Branch Node',
|
||||
},
|
||||
})
|
||||
const edge = createEdge({
|
||||
source: 'selected-node',
|
||||
target: 'next-node',
|
||||
sourceHandle: 'branch-a',
|
||||
})
|
||||
|
||||
renderComponent(selectedNode, [selectedNode, nextNode], [edge])
|
||||
|
||||
expect(screen.getByText('Approved')).toBeInTheDocument()
|
||||
expect(screen.getByText('Branch Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.addParallelNode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should number question-classifier branches even when no target node is connected', () => {
|
||||
const selectedNode = createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
title: 'Classifier',
|
||||
_targetBranches: [{
|
||||
id: 'branch-b',
|
||||
name: 'Original branch name',
|
||||
}],
|
||||
},
|
||||
})
|
||||
const danglingEdge = createEdge({
|
||||
source: 'selected-node',
|
||||
target: 'missing-node',
|
||||
sourceHandle: 'branch-b',
|
||||
})
|
||||
|
||||
renderComponent(selectedNode, [selectedNode], [danglingEdge])
|
||||
|
||||
expect(screen.getByText('workflow.nodes.questionClassifiers.class 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.panel.selectNextStep')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the failure branch when the node has error handling enabled', () => {
|
||||
const selectedNode = createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Selected Node',
|
||||
error_strategy: ErrorHandleTypeEnum.failBranch,
|
||||
},
|
||||
})
|
||||
const failNode = createNode({
|
||||
id: 'fail-node',
|
||||
data: {
|
||||
type: BlockEnum.Answer,
|
||||
title: 'Failure Node',
|
||||
},
|
||||
})
|
||||
const failEdge = createEdge({
|
||||
source: 'selected-node',
|
||||
target: 'fail-node',
|
||||
sourceHandle: ErrorHandleTypeEnum.failBranch,
|
||||
})
|
||||
|
||||
renderComponent(selectedNode, [selectedNode, failNode], [failEdge])
|
||||
|
||||
expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
|
||||
expect(screen.getByText('Failure Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.addFailureBranch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,162 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import PanelOperator from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodeMetaData: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllWorkflowTools: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../change-block', () => ({
|
||||
default: () => <div data-testid="panel-operator-change-block" />,
|
||||
}))
|
||||
|
||||
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
|
||||
const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => ({
|
||||
data,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isFetching: false,
|
||||
isRefetching: false,
|
||||
isLoadingError: false,
|
||||
isRefetchError: false,
|
||||
isInitialLoading: false,
|
||||
isPaused: false,
|
||||
isEnabled: true,
|
||||
status: 'success',
|
||||
fetchStatus: 'idle',
|
||||
dataUpdatedAt: Date.now(),
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
errorUpdateCount: 0,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isPlaceholderData: false,
|
||||
isStale: false,
|
||||
promise: Promise.resolve(data),
|
||||
} as UseQueryResult<T, Error>)
|
||||
|
||||
const renderComponent = (showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<PanelOperator
|
||||
id="node-1"
|
||||
data={{
|
||||
title: 'Code Node',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
}}
|
||||
triggerClassName="panel-operator-trigger"
|
||||
onOpenChange={onOpenChange}
|
||||
showHelpLink={showHelpLink}
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
describe('PanelOperator', () => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
const handleNodeDataUpdate = vi.fn()
|
||||
const handleSyncWorkflowDraft = vi.fn()
|
||||
const handleNodeDelete = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodeDataUpdate.mockReturnValue({
|
||||
handleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
})
|
||||
mockUseNodeMetaData.mockReturnValue({
|
||||
isTypeFixed: false,
|
||||
isSingleton: false,
|
||||
isUndeletable: false,
|
||||
description: 'Node description',
|
||||
author: 'Dify',
|
||||
helpLinkUri: 'https://docs.example.com/node',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeDelete,
|
||||
handleNodesDuplicate: vi.fn(),
|
||||
handleNodeSelect,
|
||||
handleNodesCopy: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
nodesReadOnly: false,
|
||||
} as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodesSyncDraft.mockReturnValue({
|
||||
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
})
|
||||
mockUseAllWorkflowTools.mockReturnValue(createQueryResult<ToolWithProvider[]>([]))
|
||||
})
|
||||
|
||||
// The operator should open the real popup, expose actionable items, and respect help-link visibility.
|
||||
describe('Popup Interaction', () => {
|
||||
it('should open the popup and trigger single-run actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
const { container } = renderComponent(true, onOpenChange)
|
||||
|
||||
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.panel.runThisStep'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({
|
||||
id: 'node-1',
|
||||
data: { _isSingleRun: true },
|
||||
})
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should hide the help link when showHelpLink is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderComponent(false)
|
||||
|
||||
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
|
||||
|
||||
expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import matchTheSchemaType from './match-schema-type'
|
||||
import matchTheSchemaType from '../match-schema-type'
|
||||
|
||||
describe('match the schema type', () => {
|
||||
it('should return true for identical primitive types', () => {
|
||||
@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { VariableLabelInNode, VariableLabelInText } from '../index'
|
||||
|
||||
describe('variable-label index', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The barrel exports should render the node and text variants with the expected variable metadata.
|
||||
describe('Rendering', () => {
|
||||
it('should render the node variant with node label and variable type', () => {
|
||||
render(
|
||||
<VariableLabelInNode
|
||||
nodeType={BlockEnum.Code}
|
||||
nodeTitle="Source Node"
|
||||
variables={['source-node', 'answer']}
|
||||
variableType={VarType.string}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer')).toBeInTheDocument()
|
||||
expect(screen.getByText('String')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the text variant with the shortened variable path', () => {
|
||||
render(
|
||||
<VariableLabelInText
|
||||
nodeType={BlockEnum.Code}
|
||||
nodeTitle="Source Node"
|
||||
variables={['source-node', 'payload', 'answer']}
|
||||
notShowFullPath
|
||||
isExceptionVariable
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('exception-variable')).toBeInTheDocument()
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,67 @@
|
||||
import type { AnswerNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { useWorkflow } from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useWorkflow: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseWorkflow = vi.mocked(useWorkflow)
|
||||
|
||||
const createNodeData = (overrides: Partial<AnswerNodeType> = {}): AnswerNodeType => ({
|
||||
title: 'Answer',
|
||||
desc: '',
|
||||
type: BlockEnum.Answer,
|
||||
variables: [],
|
||||
answer: 'Plain answer',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AnswerNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseWorkflow.mockReturnValue({
|
||||
getBeforeNodesInSameBranchIncludeParent: () => [],
|
||||
} as unknown as ReturnType<typeof useWorkflow>)
|
||||
})
|
||||
|
||||
// The node should render the localized panel title and plain answer text.
|
||||
describe('Rendering', () => {
|
||||
it('should render the answer title and text content', () => {
|
||||
renderNodeComponent(Node, createNodeData())
|
||||
|
||||
expect(screen.getByText('workflow.nodes.answer.answer')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plain answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render referenced variables inside the readonly content', () => {
|
||||
mockUseWorkflow.mockReturnValue({
|
||||
getBeforeNodesInSameBranchIncludeParent: () => [
|
||||
createNode({
|
||||
id: 'source-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Source Node',
|
||||
},
|
||||
}),
|
||||
],
|
||||
} as unknown as ReturnType<typeof useWorkflow>)
|
||||
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
answer: 'Hello {{#source-node.name#}}',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('name')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import { VarType } from '../../types'
|
||||
import { extractFunctionParams, extractReturnType } from './code-parser'
|
||||
import { CodeLanguage } from './types'
|
||||
import { VarType } from '../../../types'
|
||||
import { extractFunctionParams, extractReturnType } from '../code-parser'
|
||||
import { CodeLanguage } from '../types'
|
||||
|
||||
const SAMPLE_CODES = {
|
||||
python3: {
|
||||
@ -0,0 +1,101 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { OnSelectBlock } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import DataSourceEmptyNode from '../index'
|
||||
|
||||
const mockUseReplaceDataSourceNode = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useReplaceDataSourceNode: mockUseReplaceDataSourceNode,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({
|
||||
onSelect,
|
||||
trigger,
|
||||
}: {
|
||||
onSelect: OnSelectBlock
|
||||
trigger: ((open?: boolean) => ReactNode) | ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
{typeof trigger === 'function' ? trigger(false) : trigger}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(BlockEnum.DataSource, {
|
||||
plugin_id: 'plugin-id',
|
||||
provider_type: 'datasource',
|
||||
provider_name: 'file',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
title: 'Local File',
|
||||
})}
|
||||
>
|
||||
select data source
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
type DataSourceEmptyNodeProps = ComponentProps<typeof DataSourceEmptyNode>
|
||||
|
||||
const createNodeProps = (): DataSourceEmptyNodeProps => ({
|
||||
id: 'data-source-empty-node',
|
||||
data: {
|
||||
width: 240,
|
||||
height: 88,
|
||||
},
|
||||
type: 'default',
|
||||
selected: false,
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
dragHandle: undefined,
|
||||
} as unknown as DataSourceEmptyNodeProps)
|
||||
|
||||
describe('DataSourceEmptyNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseReplaceDataSourceNode.mockReturnValue({
|
||||
handleReplaceNode: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
// The empty datasource node should render the add trigger and forward selector choices.
|
||||
describe('Rendering and Selection', () => {
|
||||
it('should render the datasource add trigger', () => {
|
||||
render(
|
||||
<DataSourceEmptyNode {...createNodeProps()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.dataSource.add')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.blocks.datasource')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward block selections to the replace hook', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleReplaceNode = vi.fn()
|
||||
mockUseReplaceDataSourceNode.mockReturnValue({
|
||||
handleReplaceNode,
|
||||
})
|
||||
|
||||
render(
|
||||
<DataSourceEmptyNode {...createNodeProps()} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select data source' }))
|
||||
|
||||
expect(handleReplaceNode).toHaveBeenCalledWith(BlockEnum.DataSource, {
|
||||
plugin_id: 'plugin-id',
|
||||
provider_type: 'datasource',
|
||||
provider_name: 'file',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
title: 'Local File',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,76 @@
|
||||
import type { DataSourceNodeType } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
const mockInstallPluginButton = vi.hoisted(() => vi.fn(({ uniqueIdentifier }: { uniqueIdentifier: string }) => (
|
||||
<button type="button">{uniqueIdentifier}</button>
|
||||
)))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({
|
||||
useNodePluginInstallation: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
|
||||
InstallPluginButton: mockInstallPluginButton,
|
||||
}))
|
||||
|
||||
const mockUseNodePluginInstallation = vi.mocked(useNodePluginInstallation)
|
||||
|
||||
const createNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
|
||||
title: 'Datasource',
|
||||
desc: '',
|
||||
type: BlockEnum.DataSource,
|
||||
plugin_id: 'plugin-id',
|
||||
provider_type: 'datasource',
|
||||
provider_name: 'file',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
datasource_parameters: {},
|
||||
datasource_configurations: {},
|
||||
plugin_unique_identifier: 'plugin-id@1.0.0',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DataSourceNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodePluginInstallation.mockReturnValue({
|
||||
isChecking: false,
|
||||
isMissing: false,
|
||||
uniqueIdentifier: undefined,
|
||||
canInstall: false,
|
||||
onInstallSuccess: vi.fn(),
|
||||
shouldDim: false,
|
||||
})
|
||||
})
|
||||
|
||||
// The node should only expose install affordances when the backing plugin is missing and installable.
|
||||
describe('Plugin Installation', () => {
|
||||
it('should render the install button when the datasource plugin is missing', () => {
|
||||
mockUseNodePluginInstallation.mockReturnValue({
|
||||
isChecking: false,
|
||||
isMissing: true,
|
||||
uniqueIdentifier: 'plugin-id@1.0.0',
|
||||
canInstall: true,
|
||||
onInstallSuccess: vi.fn(),
|
||||
shouldDim: true,
|
||||
})
|
||||
|
||||
render(<Node id="data-source-node" data={createNodeData()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin-id@1.0.0' })).toBeInTheDocument()
|
||||
expect(mockInstallPluginButton).toHaveBeenCalledWith(expect.objectContaining({
|
||||
uniqueIdentifier: 'plugin-id@1.0.0',
|
||||
extraIdentifiers: ['plugin-id', 'file'],
|
||||
}), undefined)
|
||||
})
|
||||
|
||||
it('should render nothing when installation is unavailable', () => {
|
||||
const { container } = render(<Node id="data-source-node" data={createNodeData()} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,93 @@
|
||||
import type { EndNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useWorkflow,
|
||||
useWorkflowVariables,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useWorkflow: vi.fn(),
|
||||
useWorkflowVariables: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseWorkflow = vi.mocked(useWorkflow)
|
||||
const mockUseWorkflowVariables = vi.mocked(useWorkflowVariables)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
|
||||
const createNodeData = (overrides: Partial<EndNodeType> = {}): EndNodeType => ({
|
||||
title: 'End',
|
||||
desc: '',
|
||||
type: BlockEnum.End,
|
||||
outputs: [{
|
||||
variable: 'answer',
|
||||
value_selector: ['source-node', 'answer'],
|
||||
}],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('EndNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseWorkflow.mockReturnValue({
|
||||
getBeforeNodesInSameBranch: () => [
|
||||
createStartNode(),
|
||||
createNode({
|
||||
id: 'source-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Source Node',
|
||||
},
|
||||
}),
|
||||
],
|
||||
} as unknown as ReturnType<typeof useWorkflow>)
|
||||
mockUseWorkflowVariables.mockReturnValue({
|
||||
getNodeAvailableVars: () => [],
|
||||
getCurrentVariableType: () => 'string',
|
||||
} as unknown as ReturnType<typeof useWorkflowVariables>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
// The node should surface only resolved outputs and ignore empty selectors.
|
||||
describe('Rendering', () => {
|
||||
it('should render resolved output labels for referenced nodes', () => {
|
||||
renderNodeComponent(Node, createNodeData())
|
||||
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer')).toBeInTheDocument()
|
||||
expect(screen.getByText('String')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to the start node when the selector node cannot be found', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
outputs: [{
|
||||
variable: 'answer',
|
||||
value_selector: ['missing-node', 'answer'],
|
||||
}],
|
||||
}))
|
||||
|
||||
expect(screen.getByText('Start')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when every output selector is empty', () => {
|
||||
const { container } = renderNodeComponent(Node, createNodeData({
|
||||
outputs: [{
|
||||
variable: 'answer',
|
||||
value_selector: [],
|
||||
}],
|
||||
}))
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,94 @@
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import IterationStartNode, { IterationStartNodeDumb } from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
|
||||
const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => ({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})
|
||||
|
||||
const FlowNode = (props: NodeProps<CommonNodeType>) => (
|
||||
<IterationStartNode {...props} />
|
||||
)
|
||||
|
||||
const renderFlowNode = () =>
|
||||
renderWorkflowFlowComponent(<div />, {
|
||||
nodes: [createNode({
|
||||
id: 'iteration-start-node',
|
||||
type: 'iterationStartNode',
|
||||
data: {
|
||||
title: 'Iteration Start',
|
||||
desc: '',
|
||||
type: BlockEnum.IterationStart,
|
||||
},
|
||||
})],
|
||||
edges: [],
|
||||
reactFlowProps: {
|
||||
nodeTypes: { iterationStartNode: FlowNode },
|
||||
},
|
||||
canvasStyle: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
})
|
||||
|
||||
describe('IterationStartNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult())
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeAdd: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
getNodesReadOnly: () => false,
|
||||
} as unknown as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
// The start marker should provide the source handle in flow mode and omit it in dumb mode.
|
||||
describe('Rendering', () => {
|
||||
it('should render the source handle in the ReactFlow context', async () => {
|
||||
const { container } = renderFlowNode()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the dumb variant without any source handle', () => {
|
||||
const { container } = render(<IterationStartNodeDumb />)
|
||||
|
||||
expect(container.querySelector('[data-handleid="source"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,12 +1,12 @@
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { KnowledgeBaseNodeType } from '../types'
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import nodeDefault from './default'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
|
||||
import nodeDefault from '../default'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { KnowledgeBaseNodeType } from '../types'
|
||||
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
@ -8,12 +8,12 @@ import {
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from './node'
|
||||
import Node from '../node'
|
||||
import {
|
||||
ChunkStructureEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from './types'
|
||||
} from '../types'
|
||||
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockUseSettingsDisplay = vi.hoisted(() => vi.fn())
|
||||
@ -36,11 +36,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', asy
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./hooks/use-settings-display', () => ({
|
||||
vi.mock('../hooks/use-settings-display', () => ({
|
||||
useSettingsDisplay: mockUseSettingsDisplay,
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-embedding-model-status', () => ({
|
||||
vi.mock('../hooks/use-embedding-model-status', () => ({
|
||||
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
|
||||
}))
|
||||
|
||||
@ -2,8 +2,8 @@ import type { ReactNode } from 'react'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import Panel from './panel'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
|
||||
import Panel from '../panel'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types'
|
||||
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockUseQuery = vi.hoisted(() => vi.fn())
|
||||
@ -35,7 +35,7 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: false }),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-config', () => ({
|
||||
vi.mock('../hooks/use-config', () => ({
|
||||
useConfig: () => ({
|
||||
handleChunkStructureChange: vi.fn(),
|
||||
handleIndexMethodChange: vi.fn(),
|
||||
@ -54,7 +54,7 @@ vi.mock('./hooks/use-config', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-embedding-model-status', () => ({
|
||||
vi.mock('../hooks/use-embedding-model-status', () => ({
|
||||
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
|
||||
}))
|
||||
|
||||
@ -92,19 +92,19 @@ vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
|
||||
default: mockSummaryIndexSetting,
|
||||
}))
|
||||
|
||||
vi.mock('./components/chunk-structure', () => ({
|
||||
vi.mock('../components/chunk-structure', () => ({
|
||||
default: mockChunkStructure,
|
||||
}))
|
||||
|
||||
vi.mock('./components/index-method', () => ({
|
||||
vi.mock('../components/index-method', () => ({
|
||||
default: () => <div data-testid="index-method" />,
|
||||
}))
|
||||
|
||||
vi.mock('./components/embedding-model', () => ({
|
||||
vi.mock('../components/embedding-model', () => ({
|
||||
default: mockEmbeddingModel,
|
||||
}))
|
||||
|
||||
vi.mock('./components/retrieval-setting', () => ({
|
||||
vi.mock('../components/retrieval-setting', () => ({
|
||||
default: () => <div data-testid="retrieval-setting" />,
|
||||
}))
|
||||
|
||||
@ -0,0 +1,93 @@
|
||||
import type { KnowledgeBaseNodeType } from '../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types'
|
||||
import useSingleRunFormParams from '../use-single-run-form-params'
|
||||
|
||||
const createPayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => ({
|
||||
title: 'Knowledge Base',
|
||||
desc: '',
|
||||
type: BlockEnum.KnowledgeBase,
|
||||
index_chunk_variable_selector: ['chunks', 'results'],
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
embedding_model: 'text-embedding-3-large',
|
||||
embedding_model_provider: 'openai',
|
||||
keyword_number: 10,
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useSingleRunFormParams', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The hook should expose the single query form and map chunk dependencies for single-run execution.
|
||||
describe('Forms', () => {
|
||||
it('should build the query form with the current run input value', () => {
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'knowledge-base-1',
|
||||
payload: createPayload(),
|
||||
runInputData: { query: 'what is dify' },
|
||||
getInputVars: vi.fn(),
|
||||
setRunInputData: vi.fn(),
|
||||
toVarInputs: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.forms).toHaveLength(1)
|
||||
expect(result.current.forms[0].inputs).toEqual([{
|
||||
label: 'workflow.nodes.common.inputVars',
|
||||
variable: 'query',
|
||||
type: InputVarType.paragraph,
|
||||
required: true,
|
||||
}])
|
||||
expect(result.current.forms[0].values).toEqual({ query: 'what is dify' })
|
||||
})
|
||||
|
||||
it('should update run input data when the query changes', () => {
|
||||
const setRunInputData = vi.fn()
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'knowledge-base-1',
|
||||
payload: createPayload(),
|
||||
runInputData: { query: 'old query' },
|
||||
getInputVars: vi.fn(),
|
||||
setRunInputData,
|
||||
toVarInputs: vi.fn(),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.forms[0].onChange({ query: 'new query' })
|
||||
})
|
||||
|
||||
expect(setRunInputData).toHaveBeenCalledWith({ query: 'new query' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dependencies', () => {
|
||||
it('should expose the chunk selector as the only dependent variable', () => {
|
||||
const payload = createPayload({
|
||||
index_chunk_variable_selector: ['node-1', 'chunks'],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'knowledge-base-1',
|
||||
payload,
|
||||
runInputData: {},
|
||||
getInputVars: vi.fn(),
|
||||
setRunInputData: vi.fn(),
|
||||
toVarInputs: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.getDependentVars()).toEqual([['node-1', 'chunks']])
|
||||
expect(result.current.getDependentVar('query')).toEqual(['node-1', 'chunks'])
|
||||
expect(result.current.getDependentVar('other')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { KnowledgeBaseNodeType } from '../types'
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
@ -9,14 +9,14 @@ import {
|
||||
ChunkStructureEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from './types'
|
||||
} from '../types'
|
||||
import {
|
||||
getKnowledgeBaseValidationIssue,
|
||||
getKnowledgeBaseValidationMessage,
|
||||
isHighQualitySearchMethod,
|
||||
isKnowledgeBaseEmbeddingIssue,
|
||||
KnowledgeBaseValidationIssueCode,
|
||||
} from './utils'
|
||||
} from '../utils'
|
||||
|
||||
const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => {
|
||||
return [
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import EmbeddingModel from './embedding-model'
|
||||
import EmbeddingModel from '../embedding-model'
|
||||
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockModelSelector = vi.hoisted(() => vi.fn(() => <div data-testid="model-selector">selector</div>))
|
||||
@ -0,0 +1,74 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ChunkStructureEnum, IndexMethodEnum } from '../../types'
|
||||
import IndexMethod from '../index-method'
|
||||
|
||||
describe('IndexMethod', () => {
|
||||
it('should render both index method options for general chunks and notify option changes', () => {
|
||||
const onIndexMethodChange = vi.fn()
|
||||
|
||||
render(
|
||||
<IndexMethod
|
||||
chunkStructure={ChunkStructureEnum.general}
|
||||
indexMethod={IndexMethodEnum.QUALIFIED}
|
||||
keywordNumber={5}
|
||||
onIndexMethodChange={onIndexMethodChange}
|
||||
onKeywordNumberChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepTwo.qualified')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetSettings.form.indexMethodEconomy')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetCreation.stepTwo.recommend')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('datasetSettings.form.indexMethodEconomy'))
|
||||
|
||||
expect(onIndexMethodChange).toHaveBeenCalledWith(IndexMethodEnum.ECONOMICAL)
|
||||
})
|
||||
|
||||
it('should update the keyword number when the economical option is active', () => {
|
||||
const onKeywordNumberChange = vi.fn()
|
||||
const { container } = render(
|
||||
<IndexMethod
|
||||
chunkStructure={ChunkStructureEnum.general}
|
||||
indexMethod={IndexMethodEnum.ECONOMICAL}
|
||||
keywordNumber={5}
|
||||
onIndexMethodChange={vi.fn()}
|
||||
onKeywordNumberChange={onKeywordNumberChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(container.querySelector('input') as HTMLInputElement, { target: { value: '7' } })
|
||||
|
||||
expect(onKeywordNumberChange).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('should disable keyword controls when readonly is enabled', () => {
|
||||
const { container } = render(
|
||||
<IndexMethod
|
||||
chunkStructure={ChunkStructureEnum.general}
|
||||
indexMethod={IndexMethodEnum.ECONOMICAL}
|
||||
keywordNumber={5}
|
||||
onIndexMethodChange={vi.fn()}
|
||||
onKeywordNumberChange={vi.fn()}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('input')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should hide the economical option for non-general chunk structures', () => {
|
||||
render(
|
||||
<IndexMethod
|
||||
chunkStructure={ChunkStructureEnum.parent_child}
|
||||
indexMethod={IndexMethodEnum.QUALIFIED}
|
||||
keywordNumber={5}
|
||||
onIndexMethodChange={vi.fn()}
|
||||
onKeywordNumberChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepTwo.qualified')).toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetSettings.form.indexMethodEconomy')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,74 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import OptionCard from '../option-card'
|
||||
|
||||
describe('OptionCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The card should expose selection, child expansion, and readonly click behavior.
|
||||
describe('Interaction', () => {
|
||||
it('should call onClick with the card id and render active children', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
<OptionCard
|
||||
id="qualified"
|
||||
selectedId="qualified"
|
||||
title="High Quality"
|
||||
description="Use embedding retrieval."
|
||||
isRecommended
|
||||
enableRadio
|
||||
onClick={onClick}
|
||||
>
|
||||
<div>Advanced controls</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepTwo.recommend')).toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced controls')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('High Quality'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith('qualified')
|
||||
})
|
||||
|
||||
it('should not trigger selection when the card is readonly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
<OptionCard
|
||||
id="economical"
|
||||
title="Economical"
|
||||
readonly
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Economical'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support function-based wrapper, class, and icon props without enabling selection', () => {
|
||||
render(
|
||||
<OptionCard
|
||||
id="inactive"
|
||||
selectedId="qualified"
|
||||
title="Inactive card"
|
||||
enableSelect={false}
|
||||
wrapperClassName={isActive => (isActive ? 'wrapper-active' : 'wrapper-inactive')}
|
||||
className={isActive => (isActive ? 'body-active' : 'body-inactive')}
|
||||
icon={isActive => <span data-testid="option-icon">{isActive ? 'active' : 'inactive'}</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Inactive card').closest('.wrapper-inactive')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('option-icon')).toHaveTextContent('inactive')
|
||||
expect(screen.getByText('Inactive card').closest('.body-inactive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,47 @@
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import { ChunkStructureEnum } from '../../../types'
|
||||
import { useChunkStructure } from '../hooks'
|
||||
|
||||
const renderIcon = (icon: ReturnType<typeof useChunkStructure>['options'][number]['icon'], isActive: boolean) => {
|
||||
if (typeof icon !== 'function')
|
||||
throw new Error('expected icon renderer')
|
||||
|
||||
return icon(isActive)
|
||||
}
|
||||
|
||||
describe('useChunkStructure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The hook should expose ordered options and a lookup map for every chunk structure variant.
|
||||
describe('Options', () => {
|
||||
it('should return all chunk structure options and map them by id', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
expect(result.current.options).toHaveLength(3)
|
||||
expect(result.current.options.map(option => option.id)).toEqual([
|
||||
ChunkStructureEnum.general,
|
||||
ChunkStructureEnum.parent_child,
|
||||
ChunkStructureEnum.question_answer,
|
||||
])
|
||||
expect(result.current.optionMap[ChunkStructureEnum.general].title).toBe('datasetCreation.stepTwo.general')
|
||||
expect(result.current.optionMap[ChunkStructureEnum.parent_child].title).toBe('datasetCreation.stepTwo.parentChild')
|
||||
expect(result.current.optionMap[ChunkStructureEnum.question_answer].title).toBe('Q&A')
|
||||
})
|
||||
|
||||
it('should expose active and inactive icon renderers for every option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalInactive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, false)}</>).container.firstChild as HTMLElement
|
||||
const generalActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, true)}</>).container.firstChild as HTMLElement
|
||||
const parentChildActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.parent_child].icon, true)}</>).container.firstChild as HTMLElement
|
||||
const questionAnswerActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.question_answer].icon, true)}</>).container.firstChild as HTMLElement
|
||||
|
||||
expect(generalInactive).toHaveClass('text-text-tertiary')
|
||||
expect(generalActive).toHaveClass('text-util-colors-indigo-indigo-600')
|
||||
expect(parentChildActive).toHaveClass('text-util-colors-blue-light-blue-light-500')
|
||||
expect(questionAnswerActive).toHaveClass('text-util-colors-teal-teal-600')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ChunkStructureEnum } from '../../types'
|
||||
import ChunkStructure from './index'
|
||||
import { ChunkStructureEnum } from '../../../types'
|
||||
import ChunkStructure from '../index'
|
||||
|
||||
const mockUseChunkStructure = vi.hoisted(() => vi.fn())
|
||||
|
||||
@ -15,15 +15,15 @@ vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
useChunkStructure: mockUseChunkStructure,
|
||||
}))
|
||||
|
||||
vi.mock('../option-card', () => ({
|
||||
vi.mock('../../option-card', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="option-card">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./selector', () => ({
|
||||
vi.mock('../selector', () => ({
|
||||
default: ({ trigger, value }: { trigger?: ReactNode, value?: string }) => (
|
||||
<div data-testid="selector">
|
||||
{value ?? 'no-value'}
|
||||
@ -32,7 +32,7 @@ vi.mock('./selector', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./instruction', () => ({
|
||||
vi.mock('../instruction', () => ({
|
||||
default: ({ className }: { className?: string }) => <div data-testid="instruction" className={className}>Instruction</div>,
|
||||
}))
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ChunkStructureEnum } from '../../../types'
|
||||
import Selector from '../selector'
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: ChunkStructureEnum.general,
|
||||
icon: <span>G</span>,
|
||||
title: 'General',
|
||||
description: 'General description',
|
||||
effectColor: 'blue',
|
||||
},
|
||||
{
|
||||
id: ChunkStructureEnum.parent_child,
|
||||
icon: <span>P</span>,
|
||||
title: 'Parent child',
|
||||
description: 'Parent child description',
|
||||
effectColor: 'purple',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ChunkStructureSelector', () => {
|
||||
it('should open the selector panel and close it after selecting an option', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Selector
|
||||
options={options}
|
||||
value={ChunkStructureEnum.general}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.change' }))
|
||||
|
||||
expect(screen.getByText('workflow.nodes.knowledgeBase.changeChunkStructure')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Parent child'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(ChunkStructureEnum.parent_child)
|
||||
expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open the selector when readonly is enabled', () => {
|
||||
render(
|
||||
<Selector
|
||||
options={options}
|
||||
onChange={vi.fn()}
|
||||
readonly
|
||||
trigger={<button type="button">custom-trigger</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'custom-trigger' }))
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Instruction from '../index'
|
||||
|
||||
const mockUseDocLink = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: mockUseDocLink,
|
||||
}))
|
||||
|
||||
describe('ChunkStructureInstruction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseDocLink.mockReturnValue((path: string) => `https://docs.example.com${path}`)
|
||||
})
|
||||
|
||||
// The instruction card should render the learning copy and link to the chunking guide.
|
||||
describe('Rendering', () => {
|
||||
it('should render the title, message, and learn-more link', () => {
|
||||
render(<Instruction className="custom-class" />)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.message')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'workflow.nodes.knowledgeBase.chunkStructureTip.learnMore' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.example.com/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,27 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Line from '../line'
|
||||
|
||||
describe('ChunkStructureInstructionLine', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The line should switch between vertical and horizontal SVG assets.
|
||||
describe('Rendering', () => {
|
||||
it('should render the vertical line by default', () => {
|
||||
const { container } = render(<Line />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toHaveAttribute('width', '2')
|
||||
expect(svg).toHaveAttribute('height', '132')
|
||||
})
|
||||
|
||||
it('should render the horizontal line when requested', () => {
|
||||
const { container } = render(<Line type="horizontal" />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toHaveAttribute('width', '240')
|
||||
expect(svg).toHaveAttribute('height', '2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,38 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
HybridSearchModeEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from '../../../types'
|
||||
import { useRetrievalSetting } from '../hooks'
|
||||
|
||||
describe('useRetrievalSetting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The hook should switch between economical and qualified retrieval option sets.
|
||||
describe('Options', () => {
|
||||
it('should return semantic, full-text, and hybrid options for qualified indexing', () => {
|
||||
const { result } = renderHook(() => useRetrievalSetting(IndexMethodEnum.QUALIFIED))
|
||||
|
||||
expect(result.current.options.map(option => option.id)).toEqual([
|
||||
RetrievalSearchMethodEnum.semantic,
|
||||
RetrievalSearchMethodEnum.fullText,
|
||||
RetrievalSearchMethodEnum.hybrid,
|
||||
])
|
||||
expect(result.current.hybridSearchModeOptions.map(option => option.id)).toEqual([
|
||||
HybridSearchModeEnum.WeightedScore,
|
||||
HybridSearchModeEnum.RerankingModel,
|
||||
])
|
||||
})
|
||||
|
||||
it('should return only keyword search for economical indexing', () => {
|
||||
const { result } = renderHook(() => useRetrievalSetting(IndexMethodEnum.ECONOMICAL))
|
||||
|
||||
expect(result.current.options.map(option => option.id)).toEqual([
|
||||
RetrievalSearchMethodEnum.keywordSearch,
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { createDocLinkMock, resolveDocLink } from '@/app/components/workflow/__tests__/i18n'
|
||||
import { IndexMethodEnum } from '../../../types'
|
||||
import RetrievalSetting from '../index'
|
||||
|
||||
const mockUseDocLink = createDocLinkMock()
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => mockUseDocLink,
|
||||
}))
|
||||
|
||||
const baseProps = {
|
||||
onRetrievalSearchMethodChange: vi.fn(),
|
||||
onHybridSearchModeChange: vi.fn(),
|
||||
onWeightedScoreChange: vi.fn(),
|
||||
onTopKChange: vi.fn(),
|
||||
onScoreThresholdChange: vi.fn(),
|
||||
onScoreThresholdEnabledChange: vi.fn(),
|
||||
onRerankingModelEnabledChange: vi.fn(),
|
||||
onRerankingModelChange: vi.fn(),
|
||||
topK: 3,
|
||||
scoreThreshold: 0.5,
|
||||
isScoreThresholdEnabled: false,
|
||||
}
|
||||
|
||||
describe('RetrievalSetting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the learn-more link and qualified retrieval method options', () => {
|
||||
render(
|
||||
<RetrievalSetting
|
||||
{...baseProps}
|
||||
indexMethod={IndexMethodEnum.QUALIFIED}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' })).toHaveAttribute(
|
||||
'href',
|
||||
resolveDocLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods'),
|
||||
)
|
||||
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only the economical retrieval method for economical indexing', () => {
|
||||
render(
|
||||
<RetrievalSetting
|
||||
{...baseProps}
|
||||
indexMethod={IndexMethodEnum.ECONOMICAL}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument()
|
||||
expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('dataset.retrieval.hybrid_search.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,15 +1,14 @@
|
||||
import type {
|
||||
DefaultModel,
|
||||
Model,
|
||||
ModelItem,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import RerankingModelSelector from './reranking-model-selector'
|
||||
createModel,
|
||||
createModelItem,
|
||||
} from '@/app/components/workflow/__tests__/model-provider-fixtures'
|
||||
import RerankingModelSelector from '../reranking-model-selector'
|
||||
|
||||
type MockModelSelectorProps = {
|
||||
defaultModel?: DefaultModel
|
||||
@ -37,38 +36,19 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
|
||||
),
|
||||
}))
|
||||
|
||||
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'rerank-v3',
|
||||
label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' },
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
provider: 'cohere',
|
||||
icon_small: {
|
||||
en_US: 'https://example.com/cohere.png',
|
||||
zh_Hans: 'https://example.com/cohere.png',
|
||||
},
|
||||
icon_small_dark: {
|
||||
en_US: 'https://example.com/cohere-dark.png',
|
||||
zh_Hans: 'https://example.com/cohere-dark.png',
|
||||
},
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [createModelItem()],
|
||||
status: ModelStatusEnum.active,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('RerankingModelSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseModelListAndDefaultModel.mockReturnValue({
|
||||
modelList: [createModel()],
|
||||
modelList: [createModel({
|
||||
provider: 'cohere',
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [createModelItem({
|
||||
model: 'rerank-v3',
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' },
|
||||
})],
|
||||
})],
|
||||
defaultModel: undefined,
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,229 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react'
|
||||
import {
|
||||
HybridSearchModeEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
WeightedScoreEnum,
|
||||
} from '../../../types'
|
||||
import SearchMethodOption from '../search-method-option'
|
||||
|
||||
const mockUseModelListAndDefaultModel = vi.hoisted(() => vi.fn())
|
||||
const mockUseProviderContext = vi.hoisted(() => vi.fn())
|
||||
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/header/account-setting/model-provider-page/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useModelListAndDefaultModel: (...args: Parameters<typeof actual.useModelListAndDefaultModel>) => mockUseModelListAndDefaultModel(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: (...args: unknown[]) => mockUseCredentialPanelState(...args),
|
||||
}))
|
||||
|
||||
const SearchIcon: ComponentType<SVGProps<SVGSVGElement>> = props => (
|
||||
<svg aria-hidden="true" {...props} />
|
||||
)
|
||||
|
||||
const hybridSearchModeOptions = [
|
||||
{
|
||||
id: HybridSearchModeEnum.WeightedScore,
|
||||
title: 'Weighted mode',
|
||||
description: 'Use weighted score',
|
||||
},
|
||||
{
|
||||
id: HybridSearchModeEnum.RerankingModel,
|
||||
title: 'Rerank mode',
|
||||
description: 'Use reranking model',
|
||||
},
|
||||
]
|
||||
|
||||
const weightedScore = {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.8,
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-large',
|
||||
},
|
||||
keyword_setting: {
|
||||
keyword_weight: 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
const createProps = () => ({
|
||||
option: {
|
||||
id: RetrievalSearchMethodEnum.semantic,
|
||||
icon: SearchIcon,
|
||||
title: 'Semantic title',
|
||||
description: 'Semantic description',
|
||||
effectColor: 'purple',
|
||||
},
|
||||
hybridSearchModeOptions,
|
||||
searchMethod: RetrievalSearchMethodEnum.semantic,
|
||||
onRetrievalSearchMethodChange: vi.fn(),
|
||||
hybridSearchMode: HybridSearchModeEnum.WeightedScore,
|
||||
onHybridSearchModeChange: vi.fn(),
|
||||
weightedScore,
|
||||
onWeightedScoreChange: vi.fn(),
|
||||
rerankingModelEnabled: false,
|
||||
onRerankingModelEnabledChange: vi.fn(),
|
||||
rerankingModel: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
onRerankingModelChange: vi.fn(),
|
||||
topK: 3,
|
||||
onTopKChange: vi.fn(),
|
||||
scoreThreshold: 0.5,
|
||||
onScoreThresholdChange: vi.fn(),
|
||||
isScoreThresholdEnabled: true,
|
||||
onScoreThresholdEnabledChange: vi.fn(),
|
||||
showMultiModalTip: false,
|
||||
})
|
||||
|
||||
describe('SearchMethodOption', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseModelListAndDefaultModel.mockReturnValue({
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [],
|
||||
})
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-active',
|
||||
priority: 'apiKeyOnly',
|
||||
supportsCredits: false,
|
||||
showPrioritySwitcher: false,
|
||||
hasCredentials: true,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render semantic search controls and notify retrieval and reranking changes', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(<SearchMethodOption {...props} />)
|
||||
|
||||
expect(screen.getByText('Semantic title')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByText('Semantic title'))
|
||||
fireEvent.click(screen.getAllByRole('switch')[0])
|
||||
|
||||
expect(props.onRetrievalSearchMethodChange).toHaveBeenCalledWith(RetrievalSearchMethodEnum.semantic)
|
||||
expect(props.onRerankingModelEnabledChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should render the reranking switch for full-text search as well', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(
|
||||
<SearchMethodOption
|
||||
{...props}
|
||||
option={{
|
||||
...props.option,
|
||||
id: RetrievalSearchMethodEnum.fullText,
|
||||
title: 'Full-text title',
|
||||
}}
|
||||
searchMethod={RetrievalSearchMethodEnum.fullText}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Full-text title')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Full-text title'))
|
||||
|
||||
expect(props.onRetrievalSearchMethodChange).toHaveBeenCalledWith(RetrievalSearchMethodEnum.fullText)
|
||||
})
|
||||
|
||||
it('should render hybrid weighted-score controls without reranking model selector', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(
|
||||
<SearchMethodOption
|
||||
{...props}
|
||||
option={{
|
||||
...props.option,
|
||||
id: RetrievalSearchMethodEnum.hybrid,
|
||||
title: 'Hybrid title',
|
||||
}}
|
||||
searchMethod={RetrievalSearchMethodEnum.hybrid}
|
||||
hybridSearchMode={HybridSearchModeEnum.WeightedScore}
|
||||
showMultiModalTip
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Weighted mode')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rerank mode')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.rerankModel.key')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Rerank mode'))
|
||||
|
||||
expect(props.onHybridSearchModeChange).toHaveBeenCalledWith(HybridSearchModeEnum.RerankingModel)
|
||||
})
|
||||
|
||||
it('should render the hybrid reranking selector when reranking mode is selected', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(
|
||||
<SearchMethodOption
|
||||
{...props}
|
||||
option={{
|
||||
...props.option,
|
||||
id: RetrievalSearchMethodEnum.hybrid,
|
||||
title: 'Hybrid title',
|
||||
}}
|
||||
searchMethod={RetrievalSearchMethodEnum.hybrid}
|
||||
hybridSearchMode={HybridSearchModeEnum.RerankingModel}
|
||||
showMultiModalTip
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.rerankModel.key')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('dataset.weightedScore.semantic')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the score-threshold control for keyword search', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(
|
||||
<SearchMethodOption
|
||||
{...props}
|
||||
option={{
|
||||
...props.option,
|
||||
id: RetrievalSearchMethodEnum.keywordSearch,
|
||||
title: 'Keyword title',
|
||||
}}
|
||||
searchMethod={RetrievalSearchMethodEnum.keywordSearch}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '9' } })
|
||||
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(1)
|
||||
expect(screen.queryAllByRole('switch')).toHaveLength(0)
|
||||
expect(props.onTopKChange).toHaveBeenCalledWith(9)
|
||||
})
|
||||
})
|
||||
@ -32,4 +32,38 @@ describe('TopKAndScoreThreshold', () => {
|
||||
|
||||
expect(defaultProps.onScoreThresholdChange).toHaveBeenCalledWith(0.46)
|
||||
})
|
||||
|
||||
it('should hide the score-threshold column when requested', () => {
|
||||
render(<TopKAndScoreThreshold {...defaultProps} hiddenScoreThreshold />)
|
||||
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(1)
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to zero when the number fields are cleared', () => {
|
||||
render(
|
||||
<TopKAndScoreThreshold
|
||||
{...defaultProps}
|
||||
scoreThreshold={undefined}
|
||||
isScoreThresholdEnabled
|
||||
/>,
|
||||
)
|
||||
|
||||
const [topKInput, scoreThresholdInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(topKInput, { target: { value: '' } })
|
||||
|
||||
expect(defaultProps.onTopKChange).toHaveBeenCalledWith(0)
|
||||
expect(scoreThresholdInput).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should default the score-threshold switch to off when the flag is missing', () => {
|
||||
render(
|
||||
<TopKAndScoreThreshold
|
||||
{...defaultProps}
|
||||
isScoreThresholdEnabled={undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,513 @@
|
||||
import type { KnowledgeBaseNodeType } from '../../types'
|
||||
import { act } from '@testing-library/react'
|
||||
import {
|
||||
createNode,
|
||||
createNodeDataFactory,
|
||||
} from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { RerankingModeEnum } from '@/models/datasets'
|
||||
import {
|
||||
ChunkStructureEnum,
|
||||
HybridSearchModeEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
WeightedScoreEnum,
|
||||
} from '../../types'
|
||||
import { useConfig } from '../use-config'
|
||||
|
||||
const mockHandleNodeDataUpdateWithSyncDraft = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodeDataUpdate: () => ({
|
||||
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createNodeData = createNodeDataFactory<KnowledgeBaseNodeType>({
|
||||
title: 'Knowledge Base',
|
||||
desc: '',
|
||||
type: 'knowledge-base' as KnowledgeBaseNodeType['type'],
|
||||
index_chunk_variable_selector: ['chunks', 'results'],
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
embedding_model: 'text-embedding-3-large',
|
||||
embedding_model_provider: 'openai',
|
||||
keyword_number: 3,
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
summary_index_setting: {
|
||||
enable: false,
|
||||
summary_prompt: 'existing prompt',
|
||||
},
|
||||
})
|
||||
|
||||
const renderConfigHook = (nodeData: KnowledgeBaseNodeType) =>
|
||||
renderWorkflowFlowHook(() => useConfig('knowledge-base-node'), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'knowledge-base-node',
|
||||
data: nodeData,
|
||||
}),
|
||||
],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
describe('useConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should preserve the current chunk variable selector when the chunk structure does not change', () => {
|
||||
const { result } = renderConfigHook(createNodeData())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChunkStructureChange(ChunkStructureEnum.general)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
index_chunk_variable_selector: ['chunks', 'results'],
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset chunk variables and keep a high-quality search method when switching chunk structures', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.keywordSearch,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleChunkStructureChange(ChunkStructureEnum.parent_child)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
chunk_structure: ChunkStructureEnum.parent_child,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
index_chunk_variable_selector: [],
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.keywordSearch,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve semantic search when switching to a structured chunk mode from a high-quality search method', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleChunkStructureChange(ChunkStructureEnum.question_answer)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
chunk_structure: ChunkStructureEnum.question_answer,
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the index method and keyword number', () => {
|
||||
const { result } = renderConfigHook(createNodeData())
|
||||
|
||||
act(() => {
|
||||
result.current.handleIndexMethodChange(IndexMethodEnum.ECONOMICAL)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
indexing_technique: IndexMethodEnum.ECONOMICAL,
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.keywordSearch,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleIndexMethodChange(IndexMethodEnum.QUALIFIED)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeywordNumberChange(9)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: {
|
||||
keyword_number: 9,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should create default weights when embedding weights are missing and default reranking mode when switching away from hybrid', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleEmbeddingModelChange({
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
embeddingModelProvider: 'openai',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}),
|
||||
keyword_setting: expect.objectContaining({
|
||||
keyword_weight: 0.3,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRetrievalSearchMethodChange(RetrievalSearchMethodEnum.fullText)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.fullText,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should update embedding model weights and retrieval search method defaults', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
weights: {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.8,
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-large',
|
||||
},
|
||||
keyword_setting: {
|
||||
keyword_weight: 0.2,
|
||||
},
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleEmbeddingModelChange({
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
embeddingModelProvider: 'openai',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
embedding_model: 'text-embedding-3-small',
|
||||
embedding_model_provider: 'openai',
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRetrievalSearchMethodChange(RetrievalSearchMethodEnum.hybrid)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.hybrid,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
reranking_enable: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should seed hybrid weights and propagate retrieval tuning updates', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.hybrid,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleHybridSearchModeChange(HybridSearchModeEnum.WeightedScore)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
reranking_mode: HybridSearchModeEnum.WeightedScore,
|
||||
reranking_enable: false,
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-large',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRerankingModelEnabledChange(true)
|
||||
result.current.handleWeighedScoreChange({ value: [0.6, 0.4] })
|
||||
result.current.handleRerankingModelChange({
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-v3',
|
||||
})
|
||||
result.current.handleTopKChange(8)
|
||||
result.current.handleScoreThresholdChange(0.75)
|
||||
result.current.handleScoreThresholdEnabledChange(true)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(2, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
reranking_enable: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(3, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: expect.objectContaining({
|
||||
vector_weight: 0.6,
|
||||
}),
|
||||
keyword_setting: expect.objectContaining({
|
||||
keyword_weight: 0.4,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(4, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-v3',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(5, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
top_k: 8,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(6, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
score_threshold: 0.75,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(7, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
score_threshold_enabled: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should reuse existing hybrid weights and allow empty embedding defaults', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
embedding_model: undefined,
|
||||
embedding_model_provider: undefined,
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.hybrid,
|
||||
reranking_enable: false,
|
||||
reranking_mode: RerankingModeEnum.WeightedScore,
|
||||
weights: {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.9,
|
||||
embedding_provider_name: 'existing-provider',
|
||||
embedding_model_name: 'existing-model',
|
||||
},
|
||||
keyword_setting: {
|
||||
keyword_weight: 0.1,
|
||||
},
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleHybridSearchModeChange(HybridSearchModeEnum.RerankingModel)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
reranking_mode: HybridSearchModeEnum.RerankingModel,
|
||||
reranking_enable: true,
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'existing-provider',
|
||||
embedding_model_name: 'existing-model',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleEmbeddingModelChange({
|
||||
embeddingModel: 'fallback-model',
|
||||
embeddingModelProvider: '',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
embedding_model: 'fallback-model',
|
||||
embedding_model_provider: '',
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: 'fallback-model',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should normalize input variables and merge summary index settings', () => {
|
||||
const { result } = renderConfigHook(createNodeData())
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputVariableChange('chunks')
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: {
|
||||
index_chunk_variable_selector: [],
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputVariableChange(['payload', 'chunks'])
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: {
|
||||
index_chunk_variable_selector: ['payload', 'chunks'],
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSummaryIndexSettingChange({
|
||||
enable: true,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: {
|
||||
summary_index_setting: {
|
||||
enable: true,
|
||||
summary_prompt: 'existing prompt',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,81 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
createCredentialState,
|
||||
createModel,
|
||||
createModelItem,
|
||||
createProviderMeta,
|
||||
} from '@/app/components/workflow/__tests__/model-provider-fixtures'
|
||||
import { useEmbeddingModelStatus } from '../use-embedding-model-status'
|
||||
|
||||
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
|
||||
const mockUseProviderContext = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: mockUseCredentialPanelState,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: mockUseProviderContext,
|
||||
}))
|
||||
|
||||
describe('useEmbeddingModelStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [createProviderMeta({
|
||||
supported_model_types: [ModelTypeEnum.textEmbedding],
|
||||
})],
|
||||
})
|
||||
mockUseCredentialPanelState.mockReturnValue(createCredentialState())
|
||||
})
|
||||
|
||||
// The hook should resolve provider and model metadata before deriving the final status.
|
||||
describe('Resolution', () => {
|
||||
it('should return the matched provider, current model, and active status', () => {
|
||||
const embeddingModelList = [createModel()]
|
||||
|
||||
const { result } = renderHook(() => useEmbeddingModelStatus({
|
||||
embeddingModel: 'text-embedding-3-large',
|
||||
embeddingModelProvider: 'openai',
|
||||
embeddingModelList,
|
||||
}))
|
||||
|
||||
expect(result.current.providerMeta?.provider).toBe('openai')
|
||||
expect(result.current.modelProvider?.provider).toBe('openai')
|
||||
expect(result.current.currentModel?.model).toBe('text-embedding-3-large')
|
||||
expect(result.current.status).toBe('active')
|
||||
})
|
||||
|
||||
it('should return incompatible when the provider exists but the selected model is missing', () => {
|
||||
const embeddingModelList = [
|
||||
createModel({
|
||||
models: [createModelItem({ model: 'another-model' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useEmbeddingModelStatus({
|
||||
embeddingModel: 'text-embedding-3-large',
|
||||
embeddingModelProvider: 'openai',
|
||||
embeddingModelList,
|
||||
}))
|
||||
|
||||
expect(result.current.providerMeta?.provider).toBe('openai')
|
||||
expect(result.current.currentModel).toBeUndefined()
|
||||
expect(result.current.status).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('should return empty when no embedding model is configured', () => {
|
||||
const { result } = renderHook(() => useEmbeddingModelStatus({
|
||||
embeddingModel: undefined,
|
||||
embeddingModelProvider: undefined,
|
||||
embeddingModelList: [],
|
||||
}))
|
||||
|
||||
expect(result.current.providerMeta).toBeUndefined()
|
||||
expect(result.current.modelProvider).toBeUndefined()
|
||||
expect(result.current.currentModel).toBeUndefined()
|
||||
expect(result.current.status).toBe('empty')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,26 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from '../../types'
|
||||
import { useSettingsDisplay } from '../use-settings-display'
|
||||
|
||||
describe('useSettingsDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The display map should expose translated labels for all index and retrieval settings.
|
||||
describe('Translations', () => {
|
||||
it('should return translated labels for each supported setting key', () => {
|
||||
const { result } = renderHook(() => useSettingsDisplay())
|
||||
|
||||
expect(result.current[IndexMethodEnum.QUALIFIED]).toBe('datasetCreation.stepTwo.qualified')
|
||||
expect(result.current[IndexMethodEnum.ECONOMICAL]).toBe('datasetSettings.form.indexMethodEconomy')
|
||||
expect(result.current[RetrievalSearchMethodEnum.semantic]).toBe('dataset.retrieval.semantic_search.title')
|
||||
expect(result.current[RetrievalSearchMethodEnum.fullText]).toBe('dataset.retrieval.full_text_search.title')
|
||||
expect(result.current[RetrievalSearchMethodEnum.hybrid]).toBe('dataset.retrieval.hybrid_search.title')
|
||||
expect(result.current[RetrievalSearchMethodEnum.keywordSearch]).toBe('dataset.retrieval.keyword_search.title')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { LLMNodeType } from './types'
|
||||
import type { LLMNodeType } from '../types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { EditionType, PromptRole } from '../../types'
|
||||
import nodeDefault from './default'
|
||||
import { EditionType, PromptRole } from '../../../types'
|
||||
import nodeDefault from '../default'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LLMNodeType } from './types'
|
||||
import type { LLMNodeType } from '../types'
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
@ -14,8 +14,8 @@ import {
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { BlockEnum } from '../../types'
|
||||
import Panel from './panel'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import Panel from '../panel'
|
||||
|
||||
const mockUseConfig = vi.fn()
|
||||
|
||||
@ -23,7 +23,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./use-config', () => ({
|
||||
vi.mock('../use-config', () => ({
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
@ -31,19 +31,19 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-param
|
||||
default: () => <div data-testid="model-parameter-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('./components/config-prompt', () => ({
|
||||
vi.mock('../components/config-prompt', () => ({
|
||||
default: () => <div data-testid="config-prompt" />,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/config-vision', () => ({
|
||||
vi.mock('../../_base/components/config-vision', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/memory-config', () => ({
|
||||
vi.mock('../../_base/components/memory-config', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/variable/var-reference-picker', () => ({
|
||||
vi.mock('../../_base/components/variable/var-reference-picker', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
@ -55,11 +55,11 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', ()
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./components/reasoning-format-config', () => ({
|
||||
vi.mock('../components/reasoning-format-config', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./components/structure-output', () => ({
|
||||
vi.mock('../components/structure-output', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from './utils'
|
||||
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from '../utils'
|
||||
|
||||
describe('llm utils', () => {
|
||||
describe('getLLMModelIssue', () => {
|
||||
@ -0,0 +1,94 @@
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import LoopStartNode, { LoopStartNodeDumb } from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
|
||||
const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => ({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})
|
||||
|
||||
const FlowNode = (props: NodeProps<CommonNodeType>) => (
|
||||
<LoopStartNode {...props} />
|
||||
)
|
||||
|
||||
const renderFlowNode = () =>
|
||||
renderWorkflowFlowComponent(<div />, {
|
||||
nodes: [createNode({
|
||||
id: 'loop-start-node',
|
||||
type: 'loopStartNode',
|
||||
data: {
|
||||
title: 'Loop Start',
|
||||
desc: '',
|
||||
type: BlockEnum.LoopStart,
|
||||
},
|
||||
})],
|
||||
edges: [],
|
||||
reactFlowProps: {
|
||||
nodeTypes: { loopStartNode: FlowNode },
|
||||
},
|
||||
canvasStyle: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
})
|
||||
|
||||
describe('LoopStartNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult())
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeAdd: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
getNodesReadOnly: () => false,
|
||||
} as unknown as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
// The loop start marker should match iteration start behavior in both real and dumb render paths.
|
||||
describe('Rendering', () => {
|
||||
it('should render the source handle in the ReactFlow context', async () => {
|
||||
const { container } = renderFlowNode()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the dumb variant without any source handle', () => {
|
||||
const { container } = render(<LoopStartNodeDumb />)
|
||||
|
||||
expect(container.querySelector('[data-handleid="source"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,58 @@
|
||||
import type { StartNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
const createNodeData = (overrides: Partial<StartNodeType> = {}): StartNodeType => ({
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
variables: [{
|
||||
label: 'Question',
|
||||
variable: 'query',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
}],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('StartNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Start variables should render required metadata and gracefully disappear when empty.
|
||||
describe('Rendering', () => {
|
||||
it('should render configured input variables and required markers', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
variables: [
|
||||
{
|
||||
label: 'Question',
|
||||
variable: 'query',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Count',
|
||||
variable: 'count',
|
||||
type: InputVarType.number,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
expect(screen.getByText('query')).toBeInTheDocument()
|
||||
expect(screen.getByText('count')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.start.required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when there are no start variables', () => {
|
||||
const { container } = renderNodeComponent(Node, createNodeData({
|
||||
variables: [],
|
||||
}))
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,46 @@
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
import { getNextExecutionTime } from '../utils/execution-time-calculator'
|
||||
|
||||
const createNodeData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
|
||||
title: 'Schedule Trigger',
|
||||
desc: '',
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
mode: 'visual',
|
||||
frequency: 'daily',
|
||||
timezone: 'UTC',
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TriggerScheduleNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The node should surface the computed next execution time for both valid and invalid schedules.
|
||||
describe('Rendering', () => {
|
||||
it('should render the next execution label and computed execution time', () => {
|
||||
const data = createNodeData()
|
||||
|
||||
renderNodeComponent(Node, data)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTime')).toBeInTheDocument()
|
||||
expect(screen.getByText(getNextExecutionTime(data))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the placeholder when cron mode has an invalid expression', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
mode: 'cron',
|
||||
cron_expression: 'invalid cron',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||
import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
|
||||
import type { ScheduleTriggerNodeType } from '../../types'
|
||||
import { BlockEnum } from '../../../../types'
|
||||
import { isValidCronExpression, parseCronExpression } from '../cron-parser'
|
||||
import { getNextExecutionTime, getNextExecutionTimes } from '../execution-time-calculator'
|
||||
|
||||
// Comprehensive integration tests for cron-parser and execution-time-calculator compatibility
|
||||
describe('cron-parser + execution-time-calculator integration', () => {
|
||||
@ -0,0 +1,47 @@
|
||||
import type { WebhookTriggerNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
const createNodeData = (overrides: Partial<WebhookTriggerNodeType> = {}): WebhookTriggerNodeType => ({
|
||||
title: 'Webhook Trigger',
|
||||
desc: '',
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
method: 'POST',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
async_mode: false,
|
||||
status_code: 200,
|
||||
response_body: '',
|
||||
variables: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TriggerWebhookNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The node should expose the webhook URL and keep a clear fallback for empty data.
|
||||
describe('Rendering', () => {
|
||||
it('should render the webhook url when it exists', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('URL')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://example.com/webhook')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the placeholder when the webhook url is empty', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
webhook_url: '',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
138
web/app/components/workflow/note-node/__tests__/index.spec.tsx
Normal file
138
web/app/components/workflow/note-node/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import type { NoteNodeType } from '../types'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { CUSTOM_NOTE_NODE } from '../constants'
|
||||
import NoteNode from '../index'
|
||||
import { NoteTheme } from '../types'
|
||||
|
||||
const {
|
||||
mockHandleEditorChange,
|
||||
mockHandleNodeDataUpdateWithSyncDraft,
|
||||
mockHandleNodeDelete,
|
||||
mockHandleNodesCopy,
|
||||
mockHandleNodesDuplicate,
|
||||
mockHandleShowAuthorChange,
|
||||
mockHandleThemeChange,
|
||||
mockSetShortcutsEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleEditorChange: vi.fn(),
|
||||
mockHandleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
mockHandleNodeDelete: vi.fn(),
|
||||
mockHandleNodesCopy: vi.fn(),
|
||||
mockHandleNodesDuplicate: vi.fn(),
|
||||
mockHandleShowAuthorChange: vi.fn(),
|
||||
mockHandleThemeChange: vi.fn(),
|
||||
mockSetShortcutsEnabled: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useNodeDataUpdate: () => ({
|
||||
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
|
||||
}),
|
||||
useNodesInteractions: () => ({
|
||||
handleNodesCopy: mockHandleNodesCopy,
|
||||
handleNodesDuplicate: mockHandleNodesDuplicate,
|
||||
handleNodeDelete: mockHandleNodeDelete,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useNote: () => ({
|
||||
handleThemeChange: mockHandleThemeChange,
|
||||
handleEditorChange: mockHandleEditorChange,
|
||||
handleShowAuthorChange: mockHandleShowAuthorChange,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow-history-store', () => ({
|
||||
useWorkflowHistoryStore: () => ({
|
||||
setShortcutsEnabled: mockSetShortcutsEnabled,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createNoteData = (overrides: Partial<NoteNodeType> = {}): NoteNodeType => ({
|
||||
title: '',
|
||||
desc: '',
|
||||
type: '' as unknown as NoteNodeType['type'],
|
||||
text: '',
|
||||
theme: NoteTheme.blue,
|
||||
author: 'Alice',
|
||||
showAuthor: true,
|
||||
width: 240,
|
||||
height: 88,
|
||||
selected: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderNoteNode = (dataOverrides: Partial<NoteNodeType> = {}) => {
|
||||
const nodeData = createNoteData(dataOverrides)
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'note-1',
|
||||
type: CUSTOM_NOTE_NODE,
|
||||
data: nodeData,
|
||||
selected: !!nodeData.selected,
|
||||
}),
|
||||
]
|
||||
|
||||
return renderWorkflowFlowComponent(
|
||||
<div />,
|
||||
{
|
||||
nodes,
|
||||
edges: [],
|
||||
reactFlowProps: {
|
||||
nodeTypes: {
|
||||
[CUSTOM_NOTE_NODE]: NoteNode,
|
||||
},
|
||||
},
|
||||
initialStoreState: {
|
||||
controlPromptEditorRerenderKey: 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('NoteNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the toolbar and author for a selected persistent note', async () => {
|
||||
renderNoteNode()
|
||||
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide the toolbar for temporary notes', () => {
|
||||
renderNoteNode({
|
||||
_isTempNode: true,
|
||||
showAuthor: false,
|
||||
})
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.note.editor.small')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clear the selected state when clicking outside the note', async () => {
|
||||
renderNoteNode()
|
||||
|
||||
fireEvent.click(document.body)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
|
||||
id: 'note-1',
|
||||
data: {
|
||||
selected: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,138 @@
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { $getRoot } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
import { NoteEditorContextProvider } from '../context'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const emptyValue = JSON.stringify({ root: { children: [] } })
|
||||
const populatedValue = JSON.stringify({
|
||||
root: {
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text: 'hello',
|
||||
type: 'text',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
textStyle: '',
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const readEditorText = (editor: LexicalEditor) => {
|
||||
let text = ''
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
text = $getRoot().getTextContent()
|
||||
})
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
const ContextProbe = ({
|
||||
onReady,
|
||||
}: {
|
||||
onReady?: (editor: LexicalEditor) => void
|
||||
}) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const selectedIsBold = useStore(state => state.selectedIsBold)
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.(editor)
|
||||
}, [editor, onReady])
|
||||
|
||||
return <div>{selectedIsBold ? 'bold' : 'not-bold'}</div>
|
||||
}
|
||||
|
||||
describe('NoteEditorContextProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Provider should expose the store and render the wrapped editor tree.
|
||||
describe('Rendering', () => {
|
||||
it('should render children with the note editor store defaults', async () => {
|
||||
let editor: LexicalEditor | null = null
|
||||
|
||||
render(
|
||||
<NoteEditorContextProvider value={emptyValue}>
|
||||
<ContextProbe onReady={instance => (editor = instance)} />
|
||||
</NoteEditorContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('not-bold')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(editor!.isEditable()).toBe(true)
|
||||
expect(readEditorText(editor!)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Invalid or empty editor state should fall back to an empty lexical state.
|
||||
describe('Editor State Initialization', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'value is malformed json',
|
||||
value: '{invalid',
|
||||
},
|
||||
{
|
||||
name: 'root has no children',
|
||||
value: emptyValue,
|
||||
},
|
||||
])('should use an empty editor state when $name', async ({ value }) => {
|
||||
let editor: LexicalEditor | null = null
|
||||
|
||||
render(
|
||||
<NoteEditorContextProvider value={value}>
|
||||
<ContextProbe onReady={instance => (editor = instance)} />
|
||||
</NoteEditorContextProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(readEditorText(editor!)).toBe('')
|
||||
})
|
||||
|
||||
it('should restore lexical content and forward editable prop', async () => {
|
||||
let editor: LexicalEditor | null = null
|
||||
|
||||
render(
|
||||
<NoteEditorContextProvider value={populatedValue} editable={false}>
|
||||
<ContextProbe onReady={instance => (editor = instance)} />
|
||||
</NoteEditorContextProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).not.toBeNull()
|
||||
expect(readEditorText(editor!)).toBe('hello')
|
||||
})
|
||||
|
||||
expect(editor!.isEditable()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,120 @@
|
||||
import type { EditorState, LexicalEditor } from 'lexical'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
import { NoteEditorContextProvider } from '../context'
|
||||
import Editor from '../editor'
|
||||
|
||||
const emptyValue = JSON.stringify({ root: { children: [] } })
|
||||
|
||||
const EditorProbe = ({
|
||||
onReady,
|
||||
}: {
|
||||
onReady?: (editor: LexicalEditor) => void
|
||||
}) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.(editor)
|
||||
}, [editor, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const renderEditor = (
|
||||
props: Partial<React.ComponentProps<typeof Editor>> = {},
|
||||
onEditorReady?: (editor: LexicalEditor) => void,
|
||||
) => {
|
||||
return render(
|
||||
<NoteEditorContextProvider value={emptyValue}>
|
||||
<>
|
||||
<Editor
|
||||
containerElement={document.createElement('div')}
|
||||
{...props}
|
||||
/>
|
||||
<EditorProbe onReady={onEditorReady} />
|
||||
</>
|
||||
</NoteEditorContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Editor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Editor should render the lexical surface with the provided placeholder.
|
||||
describe('Rendering', () => {
|
||||
it('should render the placeholder text and content editable surface', () => {
|
||||
renderEditor({ placeholder: 'Type note' })
|
||||
|
||||
expect(screen.getByText('Type note')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Focus and blur should toggle workflow shortcuts while editing content.
|
||||
describe('Focus Management', () => {
|
||||
it('should disable shortcuts on focus and re-enable them on blur', () => {
|
||||
const setShortcutsEnabled = vi.fn()
|
||||
|
||||
renderEditor({ setShortcutsEnabled })
|
||||
|
||||
const contentEditable = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.focus(contentEditable)
|
||||
fireEvent.blur(contentEditable)
|
||||
|
||||
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(1, false)
|
||||
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(2, true)
|
||||
})
|
||||
})
|
||||
|
||||
// Lexical change events should be forwarded to the external onChange callback.
|
||||
describe('Change Handling', () => {
|
||||
it('should pass editor updates through onChange', async () => {
|
||||
const changes: string[] = []
|
||||
let editor: LexicalEditor | null = null
|
||||
const handleChange = (editorState: EditorState) => {
|
||||
editorState.read(() => {
|
||||
changes.push($getRoot().getTextContent())
|
||||
})
|
||||
}
|
||||
|
||||
renderEditor({ onChange: handleChange }, instance => (editor = instance))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).not.toBeNull()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
editor!.update(() => {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
const paragraph = $createParagraphNode()
|
||||
paragraph.append($createTextNode('hello'))
|
||||
root.append(paragraph)
|
||||
}, { discrete: true })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
editor!.update(() => {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
const paragraph = $createParagraphNode()
|
||||
paragraph.append($createTextNode('hello world'))
|
||||
root.append(paragraph)
|
||||
}, { discrete: true })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(changes).toContain('hello world')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,24 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { NoteEditorContextProvider } from '../../../context'
|
||||
import FormatDetectorPlugin from '../index'
|
||||
|
||||
const emptyValue = JSON.stringify({ root: { children: [] } })
|
||||
|
||||
describe('FormatDetectorPlugin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The plugin should register its observers without rendering extra UI.
|
||||
describe('Rendering', () => {
|
||||
it('should mount inside the real note editor context without visible output', () => {
|
||||
const { container } = render(
|
||||
<NoteEditorContextProvider value={emptyValue}>
|
||||
<FormatDetectorPlugin />
|
||||
</NoteEditorContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,71 @@
|
||||
import type { createNoteEditorStore } from '../../../store'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useEffect } from 'react'
|
||||
import { NoteEditorContextProvider } from '../../../context'
|
||||
import { useNoteEditorStore } from '../../../store'
|
||||
import LinkEditorPlugin from '../index'
|
||||
|
||||
type NoteEditorStore = ReturnType<typeof createNoteEditorStore>
|
||||
|
||||
const emptyValue = JSON.stringify({ root: { children: [] } })
|
||||
|
||||
const StoreProbe = ({
|
||||
onReady,
|
||||
}: {
|
||||
onReady?: (store: NoteEditorStore) => void
|
||||
}) => {
|
||||
const store = useNoteEditorStore()
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.(store)
|
||||
}, [onReady, store])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('LinkEditorPlugin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Without an anchor element the plugin should stay hidden.
|
||||
describe('Visibility', () => {
|
||||
it('should render nothing when no link anchor is selected', () => {
|
||||
const { container } = render(
|
||||
<NoteEditorContextProvider value={emptyValue}>
|
||||
<LinkEditorPlugin containerElement={null} />
|
||||
</NoteEditorContextProvider>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the link editor when the store has an anchor element', async () => {
|
||||
let store: NoteEditorStore | null = null
|
||||
|
||||
render(
|
||||
<NoteEditorContextProvider value={emptyValue}>
|
||||
<StoreProbe onReady={instance => (store = instance)} />
|
||||
<LinkEditorPlugin containerElement={document.createElement('div')} />
|
||||
</NoteEditorContextProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store).not.toBeNull()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store!.setState({
|
||||
linkAnchorElement: document.createElement('a'),
|
||||
linkOperatorShow: false,
|
||||
selectedLinkUrl: 'https://example.com',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { NoteTheme } from '../../../types'
|
||||
import ColorPicker, { COLOR_LIST } from '../color-picker'
|
||||
|
||||
describe('NoteEditor ColorPicker', () => {
|
||||
it('should open the palette and apply the selected theme', async () => {
|
||||
const onThemeChange = vi.fn()
|
||||
const { container } = render(
|
||||
<ColorPicker theme={NoteTheme.blue} onThemeChange={onThemeChange} />,
|
||||
)
|
||||
|
||||
const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
|
||||
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const popup = document.body.querySelector('[role="tooltip"]')
|
||||
|
||||
expect(popup).toBeInTheDocument()
|
||||
|
||||
const options = popup?.querySelectorAll('.group.relative')
|
||||
|
||||
expect(options).toHaveLength(COLOR_LIST.length)
|
||||
|
||||
fireEvent.click(options?.[COLOR_LIST.length - 1] as Element)
|
||||
|
||||
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,62 @@
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import Command from '../command'
|
||||
|
||||
const { mockHandleCommand } = vi.hoisted(() => ({
|
||||
mockHandleCommand: vi.fn(),
|
||||
}))
|
||||
|
||||
let mockSelectedState = {
|
||||
selectedIsBold: false,
|
||||
selectedIsItalic: false,
|
||||
selectedIsStrikeThrough: false,
|
||||
selectedIsLink: false,
|
||||
selectedIsBullet: false,
|
||||
}
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: (selector: (state: typeof mockSelectedState) => unknown) => selector(mockSelectedState),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useCommand: () => ({
|
||||
handleCommand: mockHandleCommand,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('NoteEditor Command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSelectedState = {
|
||||
selectedIsBold: false,
|
||||
selectedIsItalic: false,
|
||||
selectedIsStrikeThrough: false,
|
||||
selectedIsLink: false,
|
||||
selectedIsBullet: false,
|
||||
}
|
||||
})
|
||||
|
||||
it('should highlight the active command and dispatch it on click', () => {
|
||||
mockSelectedState.selectedIsBold = true
|
||||
const { container } = render(<Command type="bold" />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer') as HTMLElement
|
||||
|
||||
expect(trigger).toHaveClass('bg-state-accent-active')
|
||||
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(mockHandleCommand).toHaveBeenCalledWith('bold')
|
||||
})
|
||||
|
||||
it('should keep inactive commands unhighlighted', () => {
|
||||
const { container } = render(<Command type="link" />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer') as HTMLElement
|
||||
|
||||
expect(trigger).not.toHaveClass('bg-state-accent-active')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,55 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import FontSizeSelector from '../font-size-selector'
|
||||
|
||||
const {
|
||||
mockHandleFontSize,
|
||||
mockHandleOpenFontSizeSelector,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleFontSize: vi.fn(),
|
||||
mockHandleOpenFontSizeSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
let mockFontSizeSelectorShow = false
|
||||
let mockFontSize = '12px'
|
||||
|
||||
vi.mock('../hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useFontSize: () => ({
|
||||
fontSize: mockFontSize,
|
||||
fontSizeSelectorShow: mockFontSizeSelectorShow,
|
||||
handleFontSize: mockHandleFontSize,
|
||||
handleOpenFontSizeSelector: mockHandleOpenFontSizeSelector,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('NoteEditor FontSizeSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFontSizeSelectorShow = false
|
||||
mockFontSize = '12px'
|
||||
})
|
||||
|
||||
it('should show the current font size label and request opening when clicked', () => {
|
||||
render(<FontSizeSelector />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.nodes.note.editor.small'))
|
||||
|
||||
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should select a new font size and close the popup', () => {
|
||||
mockFontSizeSelectorShow = true
|
||||
mockFontSize = '14px'
|
||||
|
||||
render(<FontSizeSelector />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.nodes.note.editor.large'))
|
||||
|
||||
expect(screen.getAllByText('workflow.nodes.note.editor.medium').length).toBeGreaterThan(0)
|
||||
expect(mockHandleFontSize).toHaveBeenCalledWith('16px')
|
||||
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,101 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NoteTheme } from '../../../types'
|
||||
import Toolbar from '../index'
|
||||
|
||||
const {
|
||||
mockHandleCommand,
|
||||
mockHandleFontSize,
|
||||
mockHandleOpenFontSizeSelector,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleCommand: vi.fn(),
|
||||
mockHandleFontSize: vi.fn(),
|
||||
mockHandleOpenFontSizeSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
let mockFontSizeSelectorShow = false
|
||||
let mockFontSize = '14px'
|
||||
let mockSelectedState = {
|
||||
selectedIsBold: false,
|
||||
selectedIsItalic: false,
|
||||
selectedIsStrikeThrough: false,
|
||||
selectedIsLink: false,
|
||||
selectedIsBullet: false,
|
||||
}
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: (selector: (state: typeof mockSelectedState) => unknown) => selector(mockSelectedState),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useCommand: () => ({
|
||||
handleCommand: mockHandleCommand,
|
||||
}),
|
||||
useFontSize: () => ({
|
||||
fontSize: mockFontSize,
|
||||
fontSizeSelectorShow: mockFontSizeSelectorShow,
|
||||
handleFontSize: mockHandleFontSize,
|
||||
handleOpenFontSizeSelector: mockHandleOpenFontSizeSelector,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('NoteEditor Toolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFontSizeSelectorShow = false
|
||||
mockFontSize = '14px'
|
||||
mockSelectedState = {
|
||||
selectedIsBold: false,
|
||||
selectedIsItalic: false,
|
||||
selectedIsStrikeThrough: false,
|
||||
selectedIsLink: false,
|
||||
selectedIsBullet: false,
|
||||
}
|
||||
})
|
||||
|
||||
it('should compose the toolbar controls and forward callbacks from color and operator actions', async () => {
|
||||
const onCopy = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
const onDuplicate = vi.fn()
|
||||
const onShowAuthorChange = vi.fn()
|
||||
const onThemeChange = vi.fn()
|
||||
const { container } = render(
|
||||
<Toolbar
|
||||
theme={NoteTheme.blue}
|
||||
onThemeChange={onThemeChange}
|
||||
onCopy={onCopy}
|
||||
onDuplicate={onDuplicate}
|
||||
onDelete={onDelete}
|
||||
showAuthor={false}
|
||||
onShowAuthorChange={onShowAuthorChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.note.editor.medium')).toBeInTheDocument()
|
||||
|
||||
const triggers = container.querySelectorAll('[data-state="closed"]')
|
||||
|
||||
fireEvent.click(triggers[0] as HTMLElement)
|
||||
|
||||
const colorOptions = document.body.querySelectorAll('[role="tooltip"] .group.relative')
|
||||
|
||||
fireEvent.click(colorOptions[colorOptions.length - 1] as Element)
|
||||
|
||||
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
|
||||
|
||||
fireEvent.click(container.querySelectorAll('[data-state="closed"]')[container.querySelectorAll('[data-state="closed"]').length - 1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText('workflow.common.copy'))
|
||||
|
||||
expect(onCopy).toHaveBeenCalledTimes(1)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onDelete).not.toHaveBeenCalled()
|
||||
expect(onDuplicate).not.toHaveBeenCalled()
|
||||
expect(onShowAuthorChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,67 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Operator from '../operator'
|
||||
|
||||
const renderOperator = (showAuthor = false) => {
|
||||
const onCopy = vi.fn()
|
||||
const onDuplicate = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
const onShowAuthorChange = vi.fn()
|
||||
|
||||
const renderResult = render(
|
||||
<Operator
|
||||
onCopy={onCopy}
|
||||
onDuplicate={onDuplicate}
|
||||
onDelete={onDelete}
|
||||
showAuthor={showAuthor}
|
||||
onShowAuthorChange={onShowAuthorChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onShowAuthorChange,
|
||||
}
|
||||
}
|
||||
|
||||
describe('NoteEditor Toolbar Operator', () => {
|
||||
it('should trigger copy, duplicate, and delete from the opened menu', () => {
|
||||
const {
|
||||
container,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
} = renderOperator()
|
||||
|
||||
const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
|
||||
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByText('workflow.common.copy'))
|
||||
|
||||
expect(onCopy).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
|
||||
fireEvent.click(screen.getByText('workflow.common.duplicate'))
|
||||
|
||||
expect(onDuplicate).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should forward the switch state through onShowAuthorChange', () => {
|
||||
const {
|
||||
container,
|
||||
onShowAuthorChange,
|
||||
} = renderOperator(true)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onShowAuthorChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import AddBlock from '../add-block'
|
||||
|
||||
@ -102,16 +103,8 @@ vi.mock('../tip-popup', () => ({
|
||||
default: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
const renderWithReactFlow = (nodes: Array<{ id: string, position: { x: number, y: number }, data: { type: BlockEnum } }>) => {
|
||||
return render(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={nodes} edges={[]} fitView />
|
||||
<AddBlock />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
const renderWithReactFlow = (nodes: Array<ReturnType<typeof createNode>>) =>
|
||||
renderWorkflowFlowComponent(<AddBlock />, { nodes, edges: [] })
|
||||
|
||||
describe('AddBlock', () => {
|
||||
beforeEach(() => {
|
||||
@ -145,7 +138,7 @@ describe('AddBlock', () => {
|
||||
|
||||
it('should hide the start tab for chat mode and rag pipeline flows', async () => {
|
||||
mockIsChatMode = true
|
||||
const { rerender } = renderWithReactFlow([])
|
||||
const { unmount } = renderWithReactFlow([])
|
||||
|
||||
await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
|
||||
|
||||
@ -153,14 +146,8 @@ describe('AddBlock', () => {
|
||||
|
||||
mockIsChatMode = false
|
||||
mockFlowType = FlowType.ragPipeline
|
||||
rerender(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={[]} edges={[]} fitView />
|
||||
<AddBlock />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
)
|
||||
unmount()
|
||||
renderWithReactFlow([])
|
||||
|
||||
expect(latestBlockSelectorProps?.showStartTab).toBe(false)
|
||||
})
|
||||
@ -182,8 +169,8 @@ describe('AddBlock', () => {
|
||||
|
||||
it('should create a candidate node with an incremented title when a block is selected', async () => {
|
||||
renderWithReactFlow([
|
||||
{ id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer } },
|
||||
{ id: 'node-2', position: { x: 80, y: 0 }, data: { type: BlockEnum.Answer } },
|
||||
createNode({ id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer } }),
|
||||
createNode({ id: 'node-2', position: { x: 80, y: 0 }, data: { type: BlockEnum.Answer } }),
|
||||
])
|
||||
|
||||
await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
|
||||
|
||||
136
web/app/components/workflow/operator/__tests__/index.spec.tsx
Normal file
136
web/app/components/workflow/operator/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { act, screen } from '@testing-library/react'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import Operator from '../index'
|
||||
|
||||
const mockEmit = vi.fn()
|
||||
const mockDeleteAllInspectorVars = vi.fn()
|
||||
|
||||
vi.mock('../../hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: vi.fn(),
|
||||
}),
|
||||
useWorkflowReadOnly: () => ({
|
||||
workflowReadOnly: false,
|
||||
getWorkflowReadOnly: () => false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
|
||||
default: () => ({
|
||||
conversationVars: [],
|
||||
systemVars: [],
|
||||
nodesWithInspectVars: [],
|
||||
deleteAllInspectorVars: mockDeleteAllInspectorVars,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: mockEmit,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const originalResizeObserver = globalThis.ResizeObserver
|
||||
let resizeObserverCallback: ResizeObserverCallback | undefined
|
||||
const observeSpy = vi.fn()
|
||||
const disconnectSpy = vi.fn()
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeObserverCallback = callback
|
||||
}
|
||||
|
||||
observe(...args: Parameters<ResizeObserver['observe']>) {
|
||||
observeSpy(...args)
|
||||
}
|
||||
|
||||
unobserve() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
disconnectSpy()
|
||||
}
|
||||
}
|
||||
|
||||
const renderOperator = (initialStoreState: Record<string, unknown> = {}) => {
|
||||
return renderWorkflowFlowComponent(
|
||||
<Operator handleUndo={vi.fn()} handleRedo={vi.fn()} />,
|
||||
{
|
||||
nodes: [createNode({
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
},
|
||||
})],
|
||||
edges: [],
|
||||
initialStoreState,
|
||||
historyStore: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('Operator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resizeObserverCallback = undefined
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver as unknown as typeof ResizeObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver = originalResizeObserver
|
||||
})
|
||||
|
||||
it('should keep the operator width on the 400px floor when the available width is smaller', () => {
|
||||
const { container } = renderOperator({
|
||||
workflowCanvasWidth: 620,
|
||||
rightPanelWidth: 350,
|
||||
})
|
||||
|
||||
expect(screen.getByText('workflow.debug.variableInspect.trigger.normal')).toBeInTheDocument()
|
||||
expect(container.querySelector('div[style*="width: 400px"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to auto width before layout metrics are ready', () => {
|
||||
const { container } = renderOperator()
|
||||
|
||||
expect(container.querySelector('div[style*="width: auto"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should sync the observed panel size back into the workflow store and disconnect on unmount', () => {
|
||||
const { store, unmount } = renderOperator({
|
||||
workflowCanvasWidth: 900,
|
||||
rightPanelWidth: 260,
|
||||
})
|
||||
|
||||
expect(observeSpy).toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
resizeObserverCallback?.([
|
||||
{
|
||||
borderBoxSize: [{ inlineSize: 512, blockSize: 188 }],
|
||||
} as unknown as ResizeObserverEntry,
|
||||
], {} as ResizeObserver)
|
||||
})
|
||||
|
||||
expect(store.getState().bottomPanelWidth).toBe(512)
|
||||
expect(store.getState().bottomPanelHeight).toBe(188)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -3,11 +3,10 @@ import type { RunFile } from '../../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { createStartNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { InputVarType, WorkflowRunningStatus } from '../../types'
|
||||
import InputsPanel from '../inputs-panel'
|
||||
|
||||
@ -64,18 +63,17 @@ const createHooksStoreProps = (
|
||||
|
||||
const renderInputsPanel = (
|
||||
startNode: ReturnType<typeof createStartNode>,
|
||||
options?: Parameters<typeof renderWorkflowComponent>[1],
|
||||
) => {
|
||||
return renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={[startNode]} edges={[]} fitView />
|
||||
<InputsPanel onRun={vi.fn()} />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
options,
|
||||
options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>,
|
||||
onRun = vi.fn(),
|
||||
) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<InputsPanel onRun={onRun} />,
|
||||
{
|
||||
nodes: [startNode],
|
||||
edges: [],
|
||||
...options,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('InputsPanel', () => {
|
||||
beforeEach(() => {
|
||||
@ -169,34 +167,24 @@ describe('InputsPanel', () => {
|
||||
const onRun = vi.fn()
|
||||
const handleRun = vi.fn()
|
||||
|
||||
renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={[
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
default: 'default question',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
edges={[]}
|
||||
fitView
|
||||
/>
|
||||
<InputsPanel onRun={onRun} />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
default: 'default question',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
hooksStoreProps: createHooksStoreProps({ handleRun }),
|
||||
},
|
||||
onRun,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
|
||||
@ -217,36 +205,25 @@ describe('InputsPanel', () => {
|
||||
const onRun = vi.fn()
|
||||
const handleRun = vi.fn()
|
||||
|
||||
renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={[
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: InputVarType.checkbox,
|
||||
variable: 'confirmed',
|
||||
label: 'Confirmed',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
edges={[]}
|
||||
fitView
|
||||
/>
|
||||
<InputsPanel onRun={onRun} />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: InputVarType.checkbox,
|
||||
variable: 'confirmed',
|
||||
label: 'Confirmed',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
inputs: {
|
||||
@ -266,6 +243,7 @@ describe('InputsPanel', () => {
|
||||
},
|
||||
}),
|
||||
},
|
||||
onRun,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Empty from '../empty'
|
||||
|
||||
describe('VersionHistory Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Empty state should show the reset action and forward user clicks.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onResetFilter when the reset button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onResetFilter = vi.fn()
|
||||
|
||||
render(<Empty onResetFilter={onResetFilter} />)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.filter.empty')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.versionHistory.filter.reset' }))
|
||||
|
||||
expect(onResetFilter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,10 +1,16 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
import { WorkflowVersion } from '../../../types'
|
||||
|
||||
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockSetCurrentVersion = vi.fn()
|
||||
|
||||
type MockWorkflowStoreState = {
|
||||
setShowWorkflowVersionHistoryPanel: ReturnType<typeof vi.fn>
|
||||
currentVersion: null
|
||||
setCurrentVersion: typeof mockSetCurrentVersion
|
||||
}
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => ({ id: 'test-user-id' }),
|
||||
}))
|
||||
@ -69,7 +75,7 @@ vi.mock('@/service/use-workflow', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useDSL: () => ({ handleExportDSL: vi.fn() }),
|
||||
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
|
||||
useWorkflowRun: () => ({
|
||||
@ -78,16 +84,16 @@ vi.mock('../../hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks-store', () => ({
|
||||
vi.mock('../../../hooks-store', () => ({
|
||||
useHooksStore: () => ({
|
||||
flowId: 'test-flow-id',
|
||||
flowType: 'workflow',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => {
|
||||
const state = {
|
||||
vi.mock('../../../store', () => ({
|
||||
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => {
|
||||
const state: MockWorkflowStoreState = {
|
||||
setShowWorkflowVersionHistoryPanel: vi.fn(),
|
||||
currentVersion: null,
|
||||
setCurrentVersion: mockSetCurrentVersion,
|
||||
@ -104,11 +110,11 @@ vi.mock('../../store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./delete-confirm-modal', () => ({
|
||||
vi.mock('../delete-confirm-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./restore-confirm-modal', () => ({
|
||||
vi.mock('../restore-confirm-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
@ -123,7 +129,7 @@ describe('VersionHistoryPanel', () => {
|
||||
|
||||
describe('Version Click Behavior', () => {
|
||||
it('should call handleLoadBackupDraft when draft version is selected on mount', async () => {
|
||||
const { VersionHistoryPanel } = await import('./index')
|
||||
const { VersionHistoryPanel } = await import('../index')
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
@ -137,7 +143,7 @@ describe('VersionHistoryPanel', () => {
|
||||
})
|
||||
|
||||
it('should call handleRestoreFromPublishedWorkflow when clicking published version', async () => {
|
||||
const { VersionHistoryPanel } = await import('./index')
|
||||
const { VersionHistoryPanel } = await import('../index')
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
@ -0,0 +1,151 @@
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
|
||||
import VersionHistoryItem from '../version-history-item'
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { pipelineId?: string }) => unknown) => selector({ pipelineId: undefined }),
|
||||
}))
|
||||
|
||||
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
|
||||
id: 'version-1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: undefined,
|
||||
},
|
||||
features: {},
|
||||
created_at: 1710000000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 1710000000,
|
||||
updated_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
tool_published: false,
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
rag_pipeline_variables: undefined,
|
||||
version: '2024-01-01T00:00:00Z',
|
||||
marked_name: 'Release 1',
|
||||
marked_comment: 'Initial release',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('VersionHistoryItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Draft items should auto-select on mount and hide published-only metadata.
|
||||
describe('Draft Behavior', () => {
|
||||
it('should auto-select the draft version on mount', async () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionHistoryItem
|
||||
item={createVersionHistory({
|
||||
id: 'draft-version',
|
||||
version: WorkflowVersion.Draft,
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
})}
|
||||
currentVersion={null}
|
||||
latestVersionId="latest-version"
|
||||
onClick={onClick}
|
||||
handleClickMenuItem={vi.fn()}
|
||||
isLast={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.currentDraft')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClick).toHaveBeenCalledWith(expect.objectContaining({
|
||||
version: WorkflowVersion.Draft,
|
||||
}))
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Initial release')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Published items should expose metadata and the hover context menu.
|
||||
describe('Published Items', () => {
|
||||
it('should open the context menu for a latest named version and forward restore', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClickMenuItem = vi.fn()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionHistoryItem
|
||||
item={createVersionHistory()}
|
||||
currentVersion={null}
|
||||
latestVersionId="version-1"
|
||||
onClick={onClick}
|
||||
handleClickMenuItem={handleClickMenuItem}
|
||||
isLast={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const title = screen.getByText('Release 1')
|
||||
const itemContainer = title.closest('.group')
|
||||
if (!itemContainer)
|
||||
throw new Error('Expected version history item container')
|
||||
|
||||
fireEvent.mouseEnter(itemContainer)
|
||||
|
||||
const triggerButton = await screen.findByRole('button')
|
||||
await user.click(triggerButton)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.latest')).toBeInTheDocument()
|
||||
expect(screen.getByText('Initial release')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Alice$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.restore')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.versionHistory.editVersionInfo')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.export')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.versionHistory.copyId')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
|
||||
const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer')
|
||||
if (!restoreItem)
|
||||
throw new Error('Expected restore menu item')
|
||||
|
||||
fireEvent.click(restoreItem)
|
||||
|
||||
expect(handleClickMenuItem).toHaveBeenCalledTimes(1)
|
||||
expect(handleClickMenuItem).toHaveBeenCalledWith(
|
||||
VersionHistoryContextMenuOptions.restore,
|
||||
VersionHistoryContextMenuOptions.restore,
|
||||
)
|
||||
})
|
||||
|
||||
it('should ignore clicks when the item is already selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
const item = createVersionHistory()
|
||||
|
||||
render(
|
||||
<VersionHistoryItem
|
||||
item={item}
|
||||
currentVersion={item}
|
||||
latestVersionId="other-version"
|
||||
onClick={onClick}
|
||||
handleClickMenuItem={vi.fn()}
|
||||
isLast
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Release 1'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,102 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { WorkflowVersionFilterOptions } from '../../../../types'
|
||||
import FilterItem from '../filter-item'
|
||||
import FilterSwitch from '../filter-switch'
|
||||
import Filter from '../index'
|
||||
|
||||
describe('VersionHistory Filter Components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The standalone switch should reflect state and emit checked changes.
|
||||
describe('FilterSwitch', () => {
|
||||
it('should render the switch label and emit toggled value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleSwitch = vi.fn()
|
||||
|
||||
render(<FilterSwitch enabled={false} handleSwitch={handleSwitch} />)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await user.click(screen.getByRole('switch'))
|
||||
|
||||
expect(handleSwitch).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Filter items should show the current selection and forward the option key.
|
||||
describe('FilterItem', () => {
|
||||
it('should call onClick with the selected filter key', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<FilterItem
|
||||
item={{
|
||||
key: WorkflowVersionFilterOptions.onlyYours,
|
||||
name: 'Only Yours',
|
||||
}}
|
||||
isSelected
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Only Yours')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Only Yours'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
|
||||
})
|
||||
})
|
||||
|
||||
// The composed filter popover should open, list options, and delegate actions.
|
||||
describe('Filter', () => {
|
||||
it('should open the menu and forward option and switch actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClickFilterItem = vi.fn()
|
||||
const handleSwitch = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<Filter
|
||||
filterValue={WorkflowVersionFilterOptions.all}
|
||||
isOnlyShowNamedVersions={false}
|
||||
onClickFilterItem={onClickFilterItem}
|
||||
handleSwitch={handleSwitch}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = container.querySelector('.h-6.w-6')
|
||||
if (!trigger)
|
||||
throw new Error('Expected filter trigger to exist')
|
||||
|
||||
await user.click(trigger)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.filter.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.versionHistory.filter.onlyYours')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.versionHistory.filter.onlyYours'))
|
||||
expect(onClickFilterItem).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
expect(handleSwitch).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should mark the trigger as active when a filter is applied', () => {
|
||||
const { container } = render(
|
||||
<Filter
|
||||
filterValue={WorkflowVersionFilterOptions.onlyYours}
|
||||
isOnlyShowNamedVersions={false}
|
||||
onClickFilterItem={vi.fn()}
|
||||
handleSwitch={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.bg-state-accent-active-alt')).toBeInTheDocument()
|
||||
expect(container.querySelector('.text-text-accent')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,51 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Loading from '../index'
|
||||
import Item from '../item'
|
||||
|
||||
describe('VersionHistory Loading', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Individual skeleton items should hide optional rows based on edge flags.
|
||||
describe('Item', () => {
|
||||
it('should hide the release note placeholder for the first row', () => {
|
||||
const { container } = render(
|
||||
<Item
|
||||
titleWidth="w-1/3"
|
||||
releaseNotesWidth="w-3/4"
|
||||
isFirst
|
||||
isLast={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-20')).toHaveLength(1)
|
||||
expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the timeline connector for the last row', () => {
|
||||
const { container } = render(
|
||||
<Item
|
||||
titleWidth="w-2/5"
|
||||
releaseNotesWidth="w-4/6"
|
||||
isFirst={false}
|
||||
isLast
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-20')).toHaveLength(2)
|
||||
expect(container.querySelector('.absolute.left-4.top-6')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// The loading list should render the configured number of timeline skeleton rows.
|
||||
describe('Loading List', () => {
|
||||
it('should render eight loading rows with the overlay mask', () => {
|
||||
const { container } = render(<Loading />)
|
||||
|
||||
expect(container.querySelector('.bg-dataset-chunk-list-mask-bg')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.relative.flex.gap-x-1.p-2')).toHaveLength(8)
|
||||
expect(container.querySelectorAll('.opacity-20')).toHaveLength(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,168 @@
|
||||
import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import SpecialResultPanel from '../special-result-panel'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
retryPanel: vi.fn(),
|
||||
iterationPanel: vi.fn(),
|
||||
loopPanel: vi.fn(),
|
||||
agentPanel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../retry-log', () => ({
|
||||
RetryResultPanel: ({ list }: { list: NodeTracing[] }) => {
|
||||
mocks.retryPanel(list)
|
||||
return <div data-testid="retry-result-panel">{list.length}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../iteration-log', () => ({
|
||||
IterationResultPanel: ({ list }: { list: NodeTracing[][] }) => {
|
||||
mocks.iterationPanel(list)
|
||||
return <div data-testid="iteration-result-panel">{list.length}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../loop-log', () => ({
|
||||
LoopResultPanel: ({ list }: { list: NodeTracing[][] }) => {
|
||||
mocks.loopPanel(list)
|
||||
return <div data-testid="loop-result-panel">{list.length}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../agent-log', () => ({
|
||||
AgentResultPanel: ({ agentOrToolLogItemStack }: { agentOrToolLogItemStack: AgentLogItemWithChildren[] }) => {
|
||||
mocks.agentPanel(agentOrToolLogItemStack)
|
||||
return <div data-testid="agent-result-panel">{agentOrToolLogItemStack.length}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
|
||||
id: 'trace-1',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'node-1',
|
||||
node_type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
error: '',
|
||||
elapsed_time: 0.2,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 1710000000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
finished_at: 1710000001,
|
||||
execution_metadata: undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createAgentLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
|
||||
node_execution_id: 'exec-1',
|
||||
message_id: 'message-1',
|
||||
node_id: 'node-1',
|
||||
label: 'Step 1',
|
||||
data: {},
|
||||
status: 'succeeded',
|
||||
children: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SpecialResultPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The wrapper should isolate clicks from the parent tracing card.
|
||||
describe('Event Isolation', () => {
|
||||
it('should stop click propagation at the wrapper level', () => {
|
||||
const parentClick = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<div onClick={parentClick}>
|
||||
<SpecialResultPanel />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const panelRoot = container.firstElementChild?.firstElementChild
|
||||
if (!panelRoot)
|
||||
throw new Error('Expected panel root element')
|
||||
|
||||
fireEvent.click(panelRoot)
|
||||
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Panel branches should render only when their required props are present.
|
||||
describe('Conditional Panels', () => {
|
||||
it('should render retry, iteration, loop, and agent panels when their data is provided', () => {
|
||||
const retryList = [createNodeTracing()]
|
||||
const iterationList = [[createNodeTracing({ id: 'iter-1' })]]
|
||||
const loopList = [[createNodeTracing({ id: 'loop-1' })]]
|
||||
const agentStack = [createAgentLogItem()]
|
||||
const agentMap = {
|
||||
'message-1': [createAgentLogItem()],
|
||||
}
|
||||
|
||||
render(
|
||||
<SpecialResultPanel
|
||||
showRetryDetail
|
||||
setShowRetryDetailFalse={vi.fn()}
|
||||
retryResultList={retryList}
|
||||
showIteratingDetail
|
||||
setShowIteratingDetailFalse={vi.fn()}
|
||||
iterationResultList={iterationList}
|
||||
showLoopingDetail
|
||||
setShowLoopingDetailFalse={vi.fn()}
|
||||
loopResultList={loopList}
|
||||
agentOrToolLogItemStack={agentStack}
|
||||
agentOrToolLogListMap={agentMap}
|
||||
handleShowAgentOrToolLog={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('retry-result-panel')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('iteration-result-panel')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('loop-result-panel')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('agent-result-panel')).toHaveTextContent('1')
|
||||
expect(mocks.retryPanel).toHaveBeenCalledWith(retryList)
|
||||
expect(mocks.iterationPanel).toHaveBeenCalledWith(iterationList)
|
||||
expect(mocks.loopPanel).toHaveBeenCalledWith(loopList)
|
||||
expect(mocks.agentPanel).toHaveBeenCalledWith(agentStack)
|
||||
})
|
||||
|
||||
it('should keep panels hidden when required guards are missing', () => {
|
||||
render(
|
||||
<SpecialResultPanel
|
||||
showRetryDetail
|
||||
retryResultList={[]}
|
||||
showIteratingDetail
|
||||
iterationResultList={[]}
|
||||
showLoopingDetail
|
||||
loopResultList={[]}
|
||||
agentOrToolLogItemStack={[createAgentLogItem()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('retry-result-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('iteration-result-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('loop-result-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('agent-result-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,58 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import StatusContainer from '../status-container'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
|
||||
describe('StatusContainer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
// Status styling should follow the current theme and runtime status.
|
||||
describe('Status Variants', () => {
|
||||
it('should render success styling for the light theme', () => {
|
||||
const { container } = render(
|
||||
<StatusContainer status="succeeded">
|
||||
<span>Finished</span>
|
||||
</StatusContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Finished')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('bg-workflow-display-success-bg')
|
||||
expect(container.firstElementChild).toHaveClass('text-text-success')
|
||||
expect(container.querySelector('.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight\\.svg\\)\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render failed styling for the dark theme', () => {
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.dark } as ReturnType<typeof useTheme>)
|
||||
|
||||
const { container } = render(
|
||||
<StatusContainer status="failed">
|
||||
<span>Failed</span>
|
||||
</StatusContainer>,
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('bg-workflow-display-error-bg')
|
||||
expect(container.firstElementChild).toHaveClass('text-text-warning')
|
||||
expect(container.querySelector('.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight-dark\\.svg\\)\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render warning styling for paused runs', () => {
|
||||
const { container } = render(
|
||||
<StatusContainer status="paused">
|
||||
<span>Paused</span>
|
||||
</StatusContainer>,
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('bg-workflow-display-warning-bg')
|
||||
expect(container.firstElementChild).toHaveClass('text-text-destructive')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,9 @@
|
||||
import type { WorkflowPausedDetailsResponse } from '@/models/log'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { createDocLinkMock, resolveDocLink } from '../../__tests__/i18n'
|
||||
import Status from '../status'
|
||||
|
||||
const mockDocLink = vi.fn((path: string) => `https://docs.example.com${path}`)
|
||||
const mockDocLink = createDocLinkMock()
|
||||
const mockUseWorkflowPausedDetails = vi.fn()
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
@ -79,7 +80,7 @@ describe('Status', () => {
|
||||
const learnMoreLink = screen.getByRole('link', { name: 'workflow.common.learnMore' })
|
||||
|
||||
expect(screen.getByText('EXCEPTION')).toBeInTheDocument()
|
||||
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
|
||||
expect(learnMoreLink).toHaveAttribute('href', resolveDocLink('/use-dify/debug/error-type'))
|
||||
expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/error-type')
|
||||
})
|
||||
|
||||
|
||||
@ -0,0 +1,112 @@
|
||||
import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import AgentLogTrigger from '../agent-log-trigger'
|
||||
|
||||
const createAgentLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
|
||||
node_execution_id: 'exec-1',
|
||||
message_id: 'message-1',
|
||||
node_id: 'node-1',
|
||||
label: 'Step 1',
|
||||
data: {},
|
||||
status: 'succeeded',
|
||||
children: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
|
||||
id: 'trace-1',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'node-1',
|
||||
node_type: BlockEnum.Agent,
|
||||
title: 'Agent',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
error: '',
|
||||
elapsed_time: 0.2,
|
||||
execution_metadata: {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
tool_info: {
|
||||
agent_strategy: 'Plan and execute',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 1710000000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
finished_at: 1710000001,
|
||||
agentLog: [createAgentLogItem()],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AgentLogTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Agent triggers should expose strategy text and open the log stack payload.
|
||||
describe('User Interactions', () => {
|
||||
it('should show the agent strategy and pass the log payload on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowAgentOrToolLog = vi.fn()
|
||||
const agentLog = [createAgentLogItem({ message_id: 'message-1' })]
|
||||
|
||||
render(
|
||||
<AgentLogTrigger
|
||||
nodeInfo={createNodeTracing({ agentLog })}
|
||||
onShowAgentOrToolLog={onShowAgentOrToolLog}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.agent.strategy.label')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plan and execute')).toBeInTheDocument()
|
||||
expect(screen.getByText('runLog.detail')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Plan and execute'))
|
||||
|
||||
expect(onShowAgentOrToolLog).toHaveBeenCalledWith({
|
||||
message_id: 'trace-1',
|
||||
children: agentLog,
|
||||
})
|
||||
})
|
||||
|
||||
it('should still open the detail view when no strategy label is available', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowAgentOrToolLog = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentLogTrigger
|
||||
nodeInfo={createNodeTracing({
|
||||
execution_metadata: {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
},
|
||||
})}
|
||||
onShowAgentOrToolLog={onShowAgentOrToolLog}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('runLog.detail'))
|
||||
|
||||
expect(onShowAgentOrToolLog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,149 @@
|
||||
import type { LoopDurationMap, LoopVariableMap, NodeTracing } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import LoopLogTrigger from '../loop-log-trigger'
|
||||
|
||||
const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
|
||||
id: 'trace-1',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'loop-node',
|
||||
node_type: BlockEnum.Loop,
|
||||
title: 'Loop',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
error: '',
|
||||
elapsed_time: 0.2,
|
||||
execution_metadata: {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
},
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 1710000000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
finished_at: 1710000001,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('LoopLogTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Loop triggers should summarize count/error status and forward structured details.
|
||||
describe('Structured Detail Handling', () => {
|
||||
it('should pass existing loop details, durations, and variables to the callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowLoopResultList = vi.fn()
|
||||
const detailList = [
|
||||
[createNodeTracing({ id: 'loop-1-step-1', status: 'succeeded' })],
|
||||
[createNodeTracing({ id: 'loop-2-step-1', status: 'failed' })],
|
||||
]
|
||||
const loopDurationMap: LoopDurationMap = { 0: 1.2, 1: 2.5 }
|
||||
const loopVariableMap: LoopVariableMap = { 1: { item: 'alpha' } }
|
||||
|
||||
render(
|
||||
<div onClick={vi.fn()}>
|
||||
<LoopLogTrigger
|
||||
nodeInfo={createNodeTracing({
|
||||
details: detailList,
|
||||
loopDurationMap,
|
||||
execution_metadata: {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
loop_duration_map: loopDurationMap,
|
||||
loop_variable_map: loopVariableMap,
|
||||
},
|
||||
})}
|
||||
onShowLoopResultList={onShowLoopResultList}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/workflow\.nodes\.loop\.loop/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/workflow\.nodes\.loop\.error/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onShowLoopResultList).toHaveBeenCalledWith(detailList, loopDurationMap, loopVariableMap)
|
||||
})
|
||||
|
||||
it('should reconstruct loop detail groups from execution metadata when details are absent', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowLoopResultList = vi.fn()
|
||||
const loopDurationMap: LoopDurationMap = {
|
||||
'parallel-1': 1.5,
|
||||
'2': 2.2,
|
||||
}
|
||||
const allExecutions = [
|
||||
createNodeTracing({
|
||||
id: 'parallel-child',
|
||||
execution_metadata: {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
parallel_mode_run_id: 'parallel-1',
|
||||
},
|
||||
}),
|
||||
createNodeTracing({
|
||||
id: 'serial-child',
|
||||
execution_metadata: {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
loop_id: 'loop-node',
|
||||
loop_index: 2,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
render(
|
||||
<LoopLogTrigger
|
||||
nodeInfo={createNodeTracing({
|
||||
details: undefined,
|
||||
execution_metadata: {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
loop_duration_map: loopDurationMap,
|
||||
loop_variable_map: {},
|
||||
},
|
||||
})}
|
||||
allExecutions={allExecutions}
|
||||
onShowLoopResultList={onShowLoopResultList}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onShowLoopResultList).toHaveBeenCalledTimes(1)
|
||||
const [structuredList, durations, variableMap] = onShowLoopResultList.mock.calls[0]
|
||||
expect(structuredList).toHaveLength(2)
|
||||
expect(structuredList).toEqual(
|
||||
expect.arrayContaining([
|
||||
[allExecutions[0]],
|
||||
[allExecutions[1]],
|
||||
]),
|
||||
)
|
||||
expect(durations).toEqual(loopDurationMap)
|
||||
expect(variableMap).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,90 @@
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import RetryLogTrigger from '../retry-log-trigger'
|
||||
|
||||
const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
|
||||
id: 'trace-1',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'node-1',
|
||||
node_type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
error: '',
|
||||
elapsed_time: 0.2,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 1710000000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
finished_at: 1710000001,
|
||||
outputs_full_content: undefined,
|
||||
execution_metadata: undefined,
|
||||
extras: undefined,
|
||||
retryDetail: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('RetryLogTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Clicking the trigger should stop bubbling and expose the retry detail list.
|
||||
describe('User Interactions', () => {
|
||||
it('should forward retry details and stop parent clicks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowRetryResultList = vi.fn()
|
||||
const parentClick = vi.fn()
|
||||
const retryDetail = [
|
||||
createNodeTracing({ id: 'retry-1' }),
|
||||
createNodeTracing({ id: 'retry-2' }),
|
||||
]
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<RetryLogTrigger
|
||||
nodeInfo={createNodeTracing({ retryDetail })}
|
||||
onShowRetryResultList={onShowRetryResultList}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.retry.retries:{"num":2}' }))
|
||||
|
||||
expect(onShowRetryResultList).toHaveBeenCalledWith(retryDetail)
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to an empty retry list when details are missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowRetryResultList = vi.fn()
|
||||
|
||||
render(
|
||||
<RetryLogTrigger
|
||||
nodeInfo={createNodeTracing({ retryDetail: undefined })}
|
||||
onShowRetryResultList={onShowRetryResultList}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onShowRetryResultList).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import parseDSL from './graph-to-log-struct'
|
||||
import parseDSL from '../graph-to-log-struct'
|
||||
|
||||
describe('parseDSL', () => {
|
||||
it('should parse plain nodes correctly', () => {
|
||||
@ -0,0 +1,13 @@
|
||||
import format from '..'
|
||||
import { agentNodeData, multiStepsCircle, oneStepCircle } from '../data'
|
||||
|
||||
describe('agent', () => {
|
||||
it('list should transform to tree', () => {
|
||||
expect(format(agentNodeData.in as unknown as Parameters<typeof format>[0])).toEqual(agentNodeData.expect)
|
||||
})
|
||||
|
||||
it('list should remove circle log item', () => {
|
||||
expect(format(oneStepCircle.in as unknown as Parameters<typeof format>[0])).toEqual(oneStepCircle.expect)
|
||||
expect(format(multiStepsCircle.in as unknown as Parameters<typeof format>[0])).toEqual(multiStepsCircle.expect)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user