mirror of https://github.com/langgenius/dify.git
feat(tests): add comprehensive test suite for workflow utilities and node creation (#32841)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: Asuka Minato <i@asukaminato.eu.org> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
parent
707bf20c29
commit
9c33923985
|
|
@ -0,0 +1,111 @@
|
|||
import type { CommonEdgeType, CommonNodeType, Edge, Node } from '../types'
|
||||
import { Position } from 'reactflow'
|
||||
import { CUSTOM_NODE } from '../constants'
|
||||
import { BlockEnum, NodeRunningStatus } from '../types'
|
||||
|
||||
let nodeIdCounter = 0
|
||||
let edgeIdCounter = 0
|
||||
|
||||
export function resetFixtureCounters() {
|
||||
nodeIdCounter = 0
|
||||
edgeIdCounter = 0
|
||||
}
|
||||
|
||||
export function createNode(
|
||||
overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {},
|
||||
): Node {
|
||||
const id = overrides.id ?? `node-${++nodeIdCounter}`
|
||||
const { data: dataOverrides, ...rest } = overrides
|
||||
return {
|
||||
id,
|
||||
type: CUSTOM_NODE,
|
||||
position: { x: 0, y: 0 },
|
||||
targetPosition: Position.Left,
|
||||
sourcePosition: Position.Right,
|
||||
data: {
|
||||
title: `Node ${id}`,
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
...dataOverrides,
|
||||
} as CommonNodeType,
|
||||
...rest,
|
||||
} as Node
|
||||
}
|
||||
|
||||
export function createStartNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
|
||||
return createNode({
|
||||
...overrides,
|
||||
data: { type: BlockEnum.Start, title: 'Start', desc: '', ...overrides.data },
|
||||
})
|
||||
}
|
||||
|
||||
export function createTriggerNode(
|
||||
triggerType: BlockEnum.TriggerSchedule | BlockEnum.TriggerWebhook | BlockEnum.TriggerPlugin = BlockEnum.TriggerWebhook,
|
||||
overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {},
|
||||
): Node {
|
||||
return createNode({
|
||||
...overrides,
|
||||
data: { type: triggerType, title: `Trigger ${triggerType}`, desc: '', ...overrides.data },
|
||||
})
|
||||
}
|
||||
|
||||
export function createIterationNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
|
||||
return createNode({
|
||||
...overrides,
|
||||
data: { type: BlockEnum.Iteration, title: 'Iteration', desc: '', ...overrides.data },
|
||||
})
|
||||
}
|
||||
|
||||
export function createLoopNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
|
||||
return createNode({
|
||||
...overrides,
|
||||
data: { type: BlockEnum.Loop, title: 'Loop', desc: '', ...overrides.data },
|
||||
})
|
||||
}
|
||||
|
||||
export function createEdge(overrides: Omit<Partial<Edge>, 'data'> & { data?: Partial<CommonEdgeType> & Record<string, unknown> } = {}): Edge {
|
||||
const { data: dataOverrides, ...rest } = overrides
|
||||
return {
|
||||
id: overrides.id ?? `edge-${overrides.source ?? 'src'}-${overrides.target ?? 'tgt'}-${++edgeIdCounter}`,
|
||||
source: 'source-node',
|
||||
target: 'target-node',
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.Code,
|
||||
...dataOverrides,
|
||||
} as CommonEdgeType,
|
||||
...rest,
|
||||
} as Edge
|
||||
}
|
||||
|
||||
export function createLinearGraph(nodeCount: number): { nodes: Node[], edges: Edge[] } {
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const type = i === 0 ? BlockEnum.Start : BlockEnum.Code
|
||||
nodes.push(createNode({
|
||||
id: `n${i}`,
|
||||
position: { x: i * 300, y: 0 },
|
||||
data: { type, title: `Node ${i}`, desc: '' },
|
||||
}))
|
||||
if (i > 0) {
|
||||
edges.push(createEdge({
|
||||
id: `e-n${i - 1}-n${i}`,
|
||||
source: `n${i - 1}`,
|
||||
target: `n${i}`,
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: i === 1 ? BlockEnum.Start : BlockEnum.Code,
|
||||
targetType: BlockEnum.Code,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
export { BlockEnum, NodeRunningStatus }
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { noop } from 'es-toolkit'
|
||||
|
||||
/**
|
||||
* Default hooks store state.
|
||||
* All function fields default to noop / vi.fn() stubs.
|
||||
* Use `createHooksStoreState(overrides)` to get a customised state object.
|
||||
*/
|
||||
export function createHooksStoreState(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
refreshAll: noop,
|
||||
|
||||
// draft sync
|
||||
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
|
||||
syncWorkflowDraftWhenPageClose: noop,
|
||||
handleRefreshWorkflowDraft: noop,
|
||||
handleBackupDraft: noop,
|
||||
handleLoadBackupDraft: noop,
|
||||
handleRestoreFromPublishedWorkflow: noop,
|
||||
|
||||
// run
|
||||
handleRun: noop,
|
||||
handleStopRun: noop,
|
||||
handleStartWorkflowRun: noop,
|
||||
handleWorkflowStartRunInWorkflow: noop,
|
||||
handleWorkflowStartRunInChatflow: noop,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow: noop,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow: noop,
|
||||
handleWorkflowTriggerPluginRunInWorkflow: noop,
|
||||
handleWorkflowRunAllTriggersInWorkflow: noop,
|
||||
|
||||
// meta
|
||||
availableNodesMetaData: undefined,
|
||||
configsMap: undefined,
|
||||
|
||||
// export / DSL
|
||||
exportCheck: vi.fn().mockResolvedValue(undefined),
|
||||
handleExportDSL: vi.fn().mockResolvedValue(undefined),
|
||||
getWorkflowRunAndTraceUrl: vi.fn().mockReturnValue({ runUrl: '', traceUrl: '' }),
|
||||
|
||||
// inspect vars
|
||||
fetchInspectVars: vi.fn().mockResolvedValue(undefined),
|
||||
hasNodeInspectVars: vi.fn().mockReturnValue(false),
|
||||
hasSetInspectVar: vi.fn().mockReturnValue(false),
|
||||
fetchInspectVarValue: vi.fn().mockResolvedValue(undefined),
|
||||
editInspectVarValue: vi.fn().mockResolvedValue(undefined),
|
||||
renameInspectVarName: vi.fn().mockResolvedValue(undefined),
|
||||
appendNodeInspectVars: noop,
|
||||
deleteInspectVar: vi.fn().mockResolvedValue(undefined),
|
||||
deleteNodeInspectorVars: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllInspectorVars: vi.fn().mockResolvedValue(undefined),
|
||||
isInspectVarEdited: vi.fn().mockReturnValue(false),
|
||||
resetToLastRunVar: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateSysVarValues: noop,
|
||||
resetConversationVar: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateConversationVarValues: noop,
|
||||
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* ReactFlow mock factory for workflow tests.
|
||||
*
|
||||
* Usage — add this to the top of any test file that imports reactflow:
|
||||
*
|
||||
* vi.mock('reactflow', async () => (await import('../__tests__/mock-reactflow')).createReactFlowMock())
|
||||
*
|
||||
* Or for more control:
|
||||
*
|
||||
* vi.mock('reactflow', async () => {
|
||||
* const base = (await import('../__tests__/mock-reactflow')).createReactFlowMock()
|
||||
* return { ...base, useReactFlow: () => ({ ...base.useReactFlow(), fitView: vi.fn() }) }
|
||||
* })
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
export function createReactFlowMock(overrides: Record<string, unknown> = {}) {
|
||||
const noopComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) =>
|
||||
React.createElement('div', { 'data-testid': 'reactflow-mock' }, children)
|
||||
noopComponent.displayName = 'ReactFlowMock'
|
||||
|
||||
const backgroundComponent: React.FC = () => null
|
||||
backgroundComponent.displayName = 'BackgroundMock'
|
||||
|
||||
return {
|
||||
// re-export the real Position enum
|
||||
Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' },
|
||||
MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' },
|
||||
ConnectionMode: { Strict: 'strict', Loose: 'loose' },
|
||||
ConnectionLineType: { Bezier: 'default', Straight: 'straight', Step: 'step', SmoothStep: 'smoothstep' },
|
||||
|
||||
// components
|
||||
default: noopComponent,
|
||||
ReactFlow: noopComponent,
|
||||
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
Background: backgroundComponent,
|
||||
MiniMap: backgroundComponent,
|
||||
Controls: backgroundComponent,
|
||||
Handle: (props: Record<string, unknown>) => React.createElement('div', { 'data-testid': 'handle', ...props }),
|
||||
BaseEdge: (props: Record<string, unknown>) => React.createElement('path', props),
|
||||
EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
|
||||
// hooks
|
||||
useReactFlow: () => ({
|
||||
setCenter: vi.fn(),
|
||||
fitView: vi.fn(),
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
zoomTo: vi.fn(),
|
||||
getNodes: vi.fn().mockReturnValue([]),
|
||||
getEdges: vi.fn().mockReturnValue([]),
|
||||
getNode: vi.fn(),
|
||||
setNodes: vi.fn(),
|
||||
setEdges: vi.fn(),
|
||||
addNodes: vi.fn(),
|
||||
addEdges: vi.fn(),
|
||||
deleteElements: vi.fn(),
|
||||
getViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }),
|
||||
setViewport: vi.fn(),
|
||||
screenToFlowPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos),
|
||||
flowToScreenPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos),
|
||||
toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }),
|
||||
viewportInitialized: true,
|
||||
}),
|
||||
|
||||
useStoreApi: () => ({
|
||||
getState: vi.fn().mockReturnValue({
|
||||
nodeInternals: new Map(),
|
||||
edges: [],
|
||||
transform: [0, 0, 1],
|
||||
d3Selection: null,
|
||||
d3Zoom: null,
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn().mockReturnValue(vi.fn()),
|
||||
}),
|
||||
|
||||
useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
|
||||
|
||||
useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
|
||||
|
||||
useStore: vi.fn().mockReturnValue(null),
|
||||
useNodes: vi.fn().mockReturnValue([]),
|
||||
useEdges: vi.fn().mockReturnValue([]),
|
||||
useViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }),
|
||||
useOnSelectionChange: vi.fn(),
|
||||
useKeyPress: vi.fn().mockReturnValue(false),
|
||||
useUpdateNodeInternals: vi.fn().mockReturnValue(vi.fn()),
|
||||
useOnViewportChange: vi.fn(),
|
||||
useNodeId: vi.fn().mockReturnValue(null),
|
||||
|
||||
// utils
|
||||
getOutgoers: vi.fn().mockReturnValue([]),
|
||||
getIncomers: vi.fn().mockReturnValue([]),
|
||||
getConnectedEdges: vi.fn().mockReturnValue([]),
|
||||
isNode: vi.fn().mockReturnValue(true),
|
||||
isEdge: vi.fn().mockReturnValue(false),
|
||||
addEdge: vi.fn().mockImplementation((_edge: unknown, edges: unknown[]) => edges),
|
||||
applyNodeChanges: vi.fn().mockImplementation((_changes: unknown[], nodes: unknown[]) => nodes),
|
||||
applyEdgeChanges: vi.fn().mockImplementation((_changes: unknown[], edges: unknown[]) => edges),
|
||||
getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
|
||||
getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
|
||||
getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
|
||||
internalsSymbol: Symbol('internals'),
|
||||
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import type { ControlMode, Node } from '../types'
|
||||
import { noop } from 'es-toolkit'
|
||||
import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../constants'
|
||||
|
||||
/**
|
||||
* Default workflow store state covering all slices.
|
||||
* Use `createWorkflowStoreState(overrides)` to get a state object
|
||||
* that can be injected via `useWorkflowStore.setState(...)` or
|
||||
* used as the return value of a mocked `useStore` selector.
|
||||
*/
|
||||
export function createWorkflowStoreState(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
// --- workflow-slice ---
|
||||
workflowRunningData: undefined,
|
||||
isListening: false,
|
||||
listeningTriggerType: null,
|
||||
listeningTriggerNodeId: null,
|
||||
listeningTriggerNodeIds: [],
|
||||
listeningTriggerIsAll: false,
|
||||
clipboardElements: [] as Node[],
|
||||
selection: null,
|
||||
bundleNodeSize: null,
|
||||
controlMode: 'pointer' as ControlMode,
|
||||
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
|
||||
showConfirm: undefined,
|
||||
controlPromptEditorRerenderKey: 0,
|
||||
showImportDSLModal: false,
|
||||
fileUploadConfig: undefined,
|
||||
|
||||
// --- node-slice ---
|
||||
showSingleRunPanel: false,
|
||||
nodeAnimation: false,
|
||||
candidateNode: undefined,
|
||||
nodeMenu: undefined,
|
||||
showAssignVariablePopup: undefined,
|
||||
hoveringAssignVariableGroupId: undefined,
|
||||
connectingNodePayload: undefined,
|
||||
enteringNodePayload: undefined,
|
||||
iterTimes: DEFAULT_ITER_TIMES,
|
||||
loopTimes: DEFAULT_LOOP_TIMES,
|
||||
iterParallelLogMap: new Map(),
|
||||
pendingSingleRun: undefined,
|
||||
|
||||
// --- panel-slice ---
|
||||
panelWidth: 420,
|
||||
showFeaturesPanel: false,
|
||||
showWorkflowVersionHistoryPanel: false,
|
||||
showInputsPanel: false,
|
||||
showDebugAndPreviewPanel: false,
|
||||
panelMenu: undefined,
|
||||
selectionMenu: undefined,
|
||||
showVariableInspectPanel: false,
|
||||
initShowLastRunTab: false,
|
||||
|
||||
// --- help-line-slice ---
|
||||
helpLineHorizontal: undefined,
|
||||
helpLineVertical: undefined,
|
||||
|
||||
// --- history-slice ---
|
||||
historyWorkflowData: undefined,
|
||||
showRunHistory: false,
|
||||
versionHistory: [],
|
||||
|
||||
// --- chat-variable-slice ---
|
||||
showChatVariablePanel: false,
|
||||
showGlobalVariablePanel: false,
|
||||
conversationVariables: [],
|
||||
|
||||
// --- env-variable-slice ---
|
||||
showEnvPanel: false,
|
||||
environmentVariables: [],
|
||||
envSecrets: {},
|
||||
|
||||
// --- form-slice ---
|
||||
inputs: {},
|
||||
files: [],
|
||||
|
||||
// --- tool-slice ---
|
||||
toolPublished: false,
|
||||
lastPublishedHasUserInput: false,
|
||||
buildInTools: undefined,
|
||||
customTools: undefined,
|
||||
workflowTools: undefined,
|
||||
mcpTools: undefined,
|
||||
|
||||
// --- version-slice ---
|
||||
draftUpdatedAt: 0,
|
||||
publishedAt: 0,
|
||||
currentVersion: null,
|
||||
isRestoring: false,
|
||||
|
||||
// --- workflow-draft-slice ---
|
||||
backupDraft: undefined,
|
||||
syncWorkflowDraftHash: '',
|
||||
isSyncingWorkflowDraft: false,
|
||||
isWorkflowDataLoaded: false,
|
||||
nodes: [] as Node[],
|
||||
|
||||
// --- inspect-vars-slice ---
|
||||
currentFocusNodeId: null,
|
||||
nodesWithInspectVars: [],
|
||||
conversationVars: [],
|
||||
|
||||
// --- layout-slice ---
|
||||
workflowCanvasWidth: undefined,
|
||||
workflowCanvasHeight: undefined,
|
||||
rightPanelWidth: undefined,
|
||||
nodePanelWidth: 420,
|
||||
previewPanelWidth: 420,
|
||||
otherPanelWidth: 420,
|
||||
bottomPanelWidth: 0,
|
||||
bottomPanelHeight: 0,
|
||||
variableInspectPanelHeight: 300,
|
||||
maximizeCanvas: false,
|
||||
|
||||
// --- setters (all default to noop, override as needed) ---
|
||||
setWorkflowRunningData: noop,
|
||||
setIsListening: noop,
|
||||
setListeningTriggerType: noop,
|
||||
setListeningTriggerNodeId: noop,
|
||||
setListeningTriggerNodeIds: noop,
|
||||
setListeningTriggerIsAll: noop,
|
||||
setClipboardElements: noop,
|
||||
setSelection: noop,
|
||||
setBundleNodeSize: noop,
|
||||
setControlMode: noop,
|
||||
setMousePosition: noop,
|
||||
setShowConfirm: noop,
|
||||
setControlPromptEditorRerenderKey: noop,
|
||||
setShowImportDSLModal: noop,
|
||||
setFileUploadConfig: noop,
|
||||
setShowSingleRunPanel: noop,
|
||||
setNodeAnimation: noop,
|
||||
setCandidateNode: noop,
|
||||
setNodeMenu: noop,
|
||||
setShowAssignVariablePopup: noop,
|
||||
setHoveringAssignVariableGroupId: noop,
|
||||
setConnectingNodePayload: noop,
|
||||
setEnteringNodePayload: noop,
|
||||
setIterTimes: noop,
|
||||
setLoopTimes: noop,
|
||||
setIterParallelLogMap: noop,
|
||||
setPendingSingleRun: noop,
|
||||
setShowFeaturesPanel: noop,
|
||||
setShowWorkflowVersionHistoryPanel: noop,
|
||||
setShowInputsPanel: noop,
|
||||
setShowDebugAndPreviewPanel: noop,
|
||||
setPanelMenu: noop,
|
||||
setSelectionMenu: noop,
|
||||
setShowVariableInspectPanel: noop,
|
||||
setInitShowLastRunTab: noop,
|
||||
setHelpLineHorizontal: noop,
|
||||
setHelpLineVertical: noop,
|
||||
setHistoryWorkflowData: noop,
|
||||
setShowRunHistory: noop,
|
||||
setVersionHistory: noop,
|
||||
setShowChatVariablePanel: noop,
|
||||
setShowGlobalVariablePanel: noop,
|
||||
setConversationVariables: noop,
|
||||
setShowEnvPanel: noop,
|
||||
setEnvironmentVariables: noop,
|
||||
setEnvSecrets: noop,
|
||||
setInputs: noop,
|
||||
setFiles: noop,
|
||||
setToolPublished: noop,
|
||||
setLastPublishedHasUserInput: noop,
|
||||
setDraftUpdatedAt: noop,
|
||||
setPublishedAt: noop,
|
||||
setCurrentVersion: noop,
|
||||
setIsRestoring: noop,
|
||||
setBackupDraft: noop,
|
||||
setSyncWorkflowDraftHash: noop,
|
||||
setIsSyncingWorkflowDraft: noop,
|
||||
setIsWorkflowDataLoaded: noop,
|
||||
setNodes: noop,
|
||||
flushPendingSync: noop,
|
||||
setCurrentFocusNodeId: noop,
|
||||
setNodesWithInspectVars: noop,
|
||||
setNodeInspectVars: noop,
|
||||
deleteAllInspectVars: noop,
|
||||
deleteNodeInspectVars: noop,
|
||||
setInspectVarValue: noop,
|
||||
resetToLastRunVar: noop,
|
||||
renameInspectVarName: noop,
|
||||
deleteInspectVar: noop,
|
||||
setWorkflowCanvasWidth: noop,
|
||||
setWorkflowCanvasHeight: noop,
|
||||
setRightPanelWidth: noop,
|
||||
setNodePanelWidth: noop,
|
||||
setPreviewPanelWidth: noop,
|
||||
setOtherPanelWidth: noop,
|
||||
setBottomPanelWidth: noop,
|
||||
setBottomPanelHeight: noop,
|
||||
setVariableInspectPanelHeight: noop,
|
||||
setMaximizeCanvas: noop,
|
||||
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import {
|
||||
formatWorkflowRunIdentifier,
|
||||
getKeyboardKeyCodeBySystem,
|
||||
getKeyboardKeyNameBySystem,
|
||||
isEventTargetInputArea,
|
||||
isMac,
|
||||
} from '../common'
|
||||
|
||||
describe('isMac', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return true when userAgent contains MAC', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(isMac()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when userAgent does not contain MAC', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(isMac()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getKeyboardKeyNameBySystem', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
function setMac() {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Macintosh' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
function setWindows() {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Windows NT' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
it('should map ctrl to ⌘ on Mac', () => {
|
||||
setMac()
|
||||
expect(getKeyboardKeyNameBySystem('ctrl')).toBe('⌘')
|
||||
})
|
||||
|
||||
it('should map alt to ⌥ on Mac', () => {
|
||||
setMac()
|
||||
expect(getKeyboardKeyNameBySystem('alt')).toBe('⌥')
|
||||
})
|
||||
|
||||
it('should map shift to ⇧ on Mac', () => {
|
||||
setMac()
|
||||
expect(getKeyboardKeyNameBySystem('shift')).toBe('⇧')
|
||||
})
|
||||
|
||||
it('should return the original key for unmapped keys on Mac', () => {
|
||||
setMac()
|
||||
expect(getKeyboardKeyNameBySystem('enter')).toBe('enter')
|
||||
})
|
||||
|
||||
it('should return the original key on non-Mac', () => {
|
||||
setWindows()
|
||||
expect(getKeyboardKeyNameBySystem('ctrl')).toBe('ctrl')
|
||||
expect(getKeyboardKeyNameBySystem('alt')).toBe('alt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getKeyboardKeyCodeBySystem', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should map ctrl to meta on Mac', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Macintosh' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('meta')
|
||||
})
|
||||
|
||||
it('should return the original key on non-Mac', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Windows NT' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('ctrl')
|
||||
})
|
||||
|
||||
it('should return the original key for unmapped keys on Mac', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Macintosh' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(getKeyboardKeyCodeBySystem('alt')).toBe('alt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEventTargetInputArea', () => {
|
||||
it('should return true for INPUT elements', () => {
|
||||
const el = document.createElement('input')
|
||||
expect(isEventTargetInputArea(el)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for TEXTAREA elements', () => {
|
||||
const el = document.createElement('textarea')
|
||||
expect(isEventTargetInputArea(el)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for contentEditable elements', () => {
|
||||
const el = document.createElement('div')
|
||||
el.contentEditable = 'true'
|
||||
expect(isEventTargetInputArea(el)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return undefined for non-input elements', () => {
|
||||
const el = document.createElement('div')
|
||||
expect(isEventTargetInputArea(el)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for contentEditable=false elements', () => {
|
||||
const el = document.createElement('div')
|
||||
el.contentEditable = 'false'
|
||||
expect(isEventTargetInputArea(el)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatWorkflowRunIdentifier', () => {
|
||||
it('should return fallback text when finishedAt is undefined', () => {
|
||||
expect(formatWorkflowRunIdentifier()).toBe(' (Running)')
|
||||
})
|
||||
|
||||
it('should return fallback text when finishedAt is 0', () => {
|
||||
expect(formatWorkflowRunIdentifier(0)).toBe(' (Running)')
|
||||
})
|
||||
|
||||
it('should capitalize custom fallback text', () => {
|
||||
expect(formatWorkflowRunIdentifier(undefined, 'pending')).toBe(' (Pending)')
|
||||
})
|
||||
|
||||
it('should format a valid timestamp', () => {
|
||||
const timestamp = 1704067200 // 2024-01-01 00:00:00 UTC
|
||||
const result = formatWorkflowRunIdentifier(timestamp)
|
||||
expect(result).toMatch(/^ \(\d{2}:\d{2}:\d{2}( [AP]M)?\)$/)
|
||||
})
|
||||
|
||||
it('should handle single-char fallback text', () => {
|
||||
expect(formatWorkflowRunIdentifier(undefined, 'x')).toBe(' (X)')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import type { DataSourceNodeType } from '../../nodes/data-source/types'
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { getDataSourceCheckParams } from '../data-source'
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
toolParametersToFormSchemas: vi.fn((params: Array<Record<string, unknown>>) =>
|
||||
params.map(p => ({
|
||||
variable: p.name,
|
||||
label: p.label || { en_US: p.name },
|
||||
type: p.type || 'string',
|
||||
required: p.required ?? false,
|
||||
form: p.form ?? 'llm',
|
||||
hide: p.hide ?? false,
|
||||
}))),
|
||||
}))
|
||||
|
||||
function createDataSourceData(overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType {
|
||||
return {
|
||||
title: 'DataSource',
|
||||
desc: '',
|
||||
type: BlockEnum.DataSource,
|
||||
plugin_id: 'plugin-ds-1',
|
||||
provider_type: CollectionType.builtIn,
|
||||
datasource_name: 'mysql_query',
|
||||
datasource_parameters: {},
|
||||
datasource_configurations: {},
|
||||
...overrides,
|
||||
} as DataSourceNodeType
|
||||
}
|
||||
|
||||
function createDataSourceCollection(overrides: Partial<ToolWithProvider> = {}): ToolWithProvider {
|
||||
return {
|
||||
id: 'ds-collection',
|
||||
plugin_id: 'plugin-ds-1',
|
||||
name: 'MySQL',
|
||||
tools: [
|
||||
{
|
||||
name: 'mysql_query',
|
||||
parameters: [
|
||||
{ name: 'query', label: { en_US: 'SQL Query', zh_Hans: 'SQL 查询' }, type: 'string', required: true },
|
||||
{ name: 'limit', label: { en_US: 'Limit' }, type: 'number', required: false, hide: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
allow_delete: true,
|
||||
is_authorized: false,
|
||||
...overrides,
|
||||
} as unknown as ToolWithProvider
|
||||
}
|
||||
|
||||
describe('getDataSourceCheckParams', () => {
|
||||
it('should extract input schema from matching data source', () => {
|
||||
const result = getDataSourceCheckParams(
|
||||
createDataSourceData(),
|
||||
[createDataSourceCollection()],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.dataSourceInputsSchema).toEqual([
|
||||
{ label: 'SQL Query', variable: 'query', type: 'string', required: true, hide: false },
|
||||
{ label: 'Limit', variable: 'limit', type: 'number', required: false, hide: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('should mark notAuthed for builtin datasource without authorization', () => {
|
||||
const result = getDataSourceCheckParams(
|
||||
createDataSourceData(),
|
||||
[createDataSourceCollection()],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.notAuthed).toBe(true)
|
||||
})
|
||||
|
||||
it('should mark as authed when is_authorized is true', () => {
|
||||
const result = getDataSourceCheckParams(
|
||||
createDataSourceData(),
|
||||
[createDataSourceCollection({ is_authorized: true })],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.notAuthed).toBe(false)
|
||||
})
|
||||
|
||||
it('should return empty schemas when data source is not found', () => {
|
||||
const result = getDataSourceCheckParams(
|
||||
createDataSourceData({ plugin_id: 'non-existent' }),
|
||||
[createDataSourceCollection()],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.dataSourceInputsSchema).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty schemas when datasource item is not found', () => {
|
||||
const result = getDataSourceCheckParams(
|
||||
createDataSourceData({ datasource_name: 'non_existent_ds' }),
|
||||
[createDataSourceCollection()],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.dataSourceInputsSchema).toEqual([])
|
||||
})
|
||||
|
||||
it('should include language in result', () => {
|
||||
const result = getDataSourceCheckParams(
|
||||
createDataSourceData(),
|
||||
[createDataSourceCollection()],
|
||||
'zh_Hans',
|
||||
)
|
||||
|
||||
expect(result.language).toBe('zh_Hans')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { VarInInspectType } from '@/types/workflow'
|
||||
import { VarType } from '../../types'
|
||||
import { outputToVarInInspect } from '../debug'
|
||||
|
||||
describe('outputToVarInInspect', () => {
|
||||
it('should create a VarInInspect object with correct fields', () => {
|
||||
const result = outputToVarInInspect({
|
||||
nodeId: 'node-1',
|
||||
name: 'output',
|
||||
value: 'hello world',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: VarInInspectType.node,
|
||||
name: 'output',
|
||||
description: '',
|
||||
selector: ['node-1', 'output'],
|
||||
value_type: VarType.string,
|
||||
value: 'hello world',
|
||||
edited: false,
|
||||
visible: true,
|
||||
is_truncated: false,
|
||||
full_content: { size_bytes: 0, download_url: '' },
|
||||
})
|
||||
expect(result.id).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle different value types', () => {
|
||||
const result = outputToVarInInspect({
|
||||
nodeId: 'n2',
|
||||
name: 'count',
|
||||
value: 42,
|
||||
})
|
||||
|
||||
expect(result.value).toBe(42)
|
||||
expect(result.selector).toEqual(['n2', 'count'])
|
||||
})
|
||||
|
||||
it('should handle null value', () => {
|
||||
const result = outputToVarInInspect({
|
||||
nodeId: 'n3',
|
||||
name: 'empty',
|
||||
value: null,
|
||||
})
|
||||
|
||||
expect(result.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { NodeRunningStatus } from '../../types'
|
||||
import { getEdgeColor } from '../edge'
|
||||
|
||||
describe('getEdgeColor', () => {
|
||||
it('should return success color when status is Succeeded', () => {
|
||||
expect(getEdgeColor(NodeRunningStatus.Succeeded)).toBe('var(--color-workflow-link-line-success-handle)')
|
||||
})
|
||||
|
||||
it('should return error color when status is Failed', () => {
|
||||
expect(getEdgeColor(NodeRunningStatus.Failed)).toBe('var(--color-workflow-link-line-error-handle)')
|
||||
})
|
||||
|
||||
it('should return failure color when status is Exception', () => {
|
||||
expect(getEdgeColor(NodeRunningStatus.Exception)).toBe('var(--color-workflow-link-line-failure-handle)')
|
||||
})
|
||||
|
||||
it('should return default running color when status is Running and not fail branch', () => {
|
||||
expect(getEdgeColor(NodeRunningStatus.Running)).toBe('var(--color-workflow-link-line-handle)')
|
||||
})
|
||||
|
||||
it('should return failure color when status is Running and is fail branch', () => {
|
||||
expect(getEdgeColor(NodeRunningStatus.Running, true)).toBe('var(--color-workflow-link-line-failure-handle)')
|
||||
})
|
||||
|
||||
it('should return normal color when status is undefined', () => {
|
||||
expect(getEdgeColor()).toBe('var(--color-workflow-link-line-normal)')
|
||||
})
|
||||
|
||||
it('should return normal color for other statuses', () => {
|
||||
expect(getEdgeColor(NodeRunningStatus.Waiting)).toBe('var(--color-workflow-link-line-normal)')
|
||||
expect(getEdgeColor(NodeRunningStatus.NotStart)).toBe('var(--color-workflow-link-line-normal)')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,665 @@
|
|||
import type { CommonEdgeType, CommonNodeType, Edge, Node } from '../../types'
|
||||
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
|
||||
import { CUSTOM_NODE, NODE_LAYOUT_HORIZONTAL_PADDING } from '../../constants'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '../../nodes/loop-start/constants'
|
||||
import { BlockEnum } from '../../types'
|
||||
|
||||
type ElkChild = Record<string, unknown> & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string }>, layoutOptions?: Record<string, string> }
|
||||
type ElkGraph = Record<string, unknown> & { id: string, children?: ElkChild[], edges?: Array<Record<string, unknown>> }
|
||||
|
||||
let layoutCallArgs: ElkGraph | null = null
|
||||
let mockReturnOverride: ((graph: ElkGraph) => ElkGraph) | null = null
|
||||
|
||||
vi.mock('elkjs/lib/elk.bundled.js', () => {
|
||||
return {
|
||||
default: class MockELK {
|
||||
async layout(graph: ElkGraph) {
|
||||
layoutCallArgs = graph
|
||||
if (mockReturnOverride)
|
||||
return mockReturnOverride(graph)
|
||||
|
||||
const children = (graph.children || []).map((child: ElkChild, i: number) => ({
|
||||
...child,
|
||||
x: 100 + i * 300,
|
||||
y: 50 + i * 100,
|
||||
width: child.width || 244,
|
||||
height: child.height || 100,
|
||||
}))
|
||||
return { ...graph, children }
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const { getLayoutByDagre, getLayoutForChildNodes } = await import('../elk-layout')
|
||||
|
||||
function makeWorkflowNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
|
||||
return createNode({
|
||||
type: CUSTOM_NODE,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function makeWorkflowEdge(overrides: Omit<Partial<Edge>, 'data'> & { data?: Partial<CommonEdgeType> & Record<string, unknown> } = {}): Edge {
|
||||
return createEdge(overrides)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetFixtureCounters()
|
||||
layoutCallArgs = null
|
||||
mockReturnOverride = null
|
||||
})
|
||||
|
||||
describe('getLayoutByDagre', () => {
|
||||
it('should return layout for simple linear graph', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [makeWorkflowEdge({ source: 'a', target: 'b' })]
|
||||
|
||||
const result = await getLayoutByDagre(nodes, edges)
|
||||
|
||||
expect(result.nodes.size).toBe(2)
|
||||
expect(result.nodes.has('a')).toBe(true)
|
||||
expect(result.nodes.has('b')).toBe(true)
|
||||
expect(result.bounds.minX).toBe(0)
|
||||
expect(result.bounds.minY).toBe(0)
|
||||
})
|
||||
|
||||
it('should filter out nodes with parentId', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'a' }),
|
||||
]
|
||||
|
||||
const result = await getLayoutByDagre(nodes, [])
|
||||
expect(result.nodes.size).toBe(1)
|
||||
expect(result.nodes.has('child')).toBe(false)
|
||||
})
|
||||
|
||||
it('should filter out non-CUSTOM_NODE type nodes', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'iter-start', type: CUSTOM_ITERATION_START_NODE, data: { type: BlockEnum.IterationStart, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
const result = await getLayoutByDagre(nodes, [])
|
||||
expect(result.nodes.size).toBe(1)
|
||||
})
|
||||
|
||||
it('should filter out iteration/loop internal edges', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'a', target: 'b', data: { isInIteration: true, iteration_id: 'iter-1' } }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
expect(layoutCallArgs!.edges).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should use default dimensions when node has no width/height', async () => {
|
||||
const node = makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
|
||||
Reflect.deleteProperty(node, 'width')
|
||||
Reflect.deleteProperty(node, 'height')
|
||||
|
||||
const result = await getLayoutByDagre([node], [])
|
||||
expect(result.nodes.size).toBe(1)
|
||||
const info = result.nodes.get('a')!
|
||||
expect(info.width).toBe(244)
|
||||
expect(info.height).toBe(100)
|
||||
})
|
||||
|
||||
it('should build ports for IfElse nodes with multiple branches', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: {
|
||||
type: BlockEnum.IfElse,
|
||||
title: '',
|
||||
desc: '',
|
||||
cases: [{ case_id: 'case-1', logical_operator: 'and', conditions: [] }],
|
||||
},
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b', sourceHandle: 'case-1' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
|
||||
expect(ifElkNode.ports).toHaveLength(2)
|
||||
expect(ifElkNode.layoutOptions!['elk.portConstraints']).toBe('FIXED_ORDER')
|
||||
})
|
||||
|
||||
it('should use normal node for IfElse with single branch', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-1' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [makeWorkflowEdge({ source: 'if-1', target: 'b', sourceHandle: 'case-1' })]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
|
||||
expect(ifElkNode.ports).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should build ports for HumanInput nodes with multiple branches', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'hi-1',
|
||||
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'action-1' }, { id: 'action-2' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'hi-1', target: 'b', sourceHandle: 'action-1' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
|
||||
expect(hiElkNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should use normal node for HumanInput with single branch', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'hi-1',
|
||||
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'action-1' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [makeWorkflowEdge({ source: 'hi-1', target: 'b', sourceHandle: 'action-1' })]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
|
||||
expect(hiElkNode.ports).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should normalise bounds so minX and minY start at 0', async () => {
|
||||
const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })]
|
||||
const result = await getLayoutByDagre(nodes, [])
|
||||
expect(result.bounds.minX).toBe(0)
|
||||
expect(result.bounds.minY).toBe(0)
|
||||
})
|
||||
|
||||
it('should return empty layout when no nodes match filter', async () => {
|
||||
const result = await getLayoutByDagre([], [])
|
||||
expect(result.nodes.size).toBe(0)
|
||||
expect(result.bounds).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 })
|
||||
})
|
||||
|
||||
it('should sort IfElse edges with false (ELSE) last', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: {
|
||||
type: BlockEnum.IfElse,
|
||||
title: '',
|
||||
desc: '',
|
||||
cases: [
|
||||
{ case_id: 'case-a', logical_operator: 'and', conditions: [] },
|
||||
{ case_id: 'case-b', logical_operator: 'and', conditions: [] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'y', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'z', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e-else', source: 'if-1', target: 'z', sourceHandle: 'false' }),
|
||||
makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'x', sourceHandle: 'case-a' }),
|
||||
makeWorkflowEdge({ id: 'e-b', source: 'if-1', target: 'y', sourceHandle: 'case-b' }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
|
||||
const portIds = ifNode.ports!.map((p: { id: string }) => p.id)
|
||||
expect(portIds[portIds.length - 1]).toContain('false')
|
||||
})
|
||||
|
||||
it('should sort HumanInput edges with __timeout last', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'hi-1',
|
||||
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'a1' }, { id: 'a2' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'y', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'z', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e-timeout', source: 'hi-1', target: 'z', sourceHandle: '__timeout' }),
|
||||
makeWorkflowEdge({ id: 'e-a1', source: 'hi-1', target: 'x', sourceHandle: 'a1' }),
|
||||
makeWorkflowEdge({ id: 'e-a2', source: 'hi-1', target: 'y', sourceHandle: 'a2' }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
|
||||
const portIds = hiNode.ports!.map((p: { id: string }) => p.id)
|
||||
expect(portIds[portIds.length - 1]).toContain('__timeout')
|
||||
})
|
||||
|
||||
it('should assign sourcePort to edges from IfElse nodes with ports', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-1' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b', sourceHandle: 'case-1' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const portEdges = layoutCallArgs!.edges!.filter((e: Record<string, unknown>) => e.sourcePort)
|
||||
expect(portEdges.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle edges without sourceHandle for ports (use index)', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const e1 = makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b' })
|
||||
const e2 = makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c' })
|
||||
Reflect.deleteProperty(e1, 'sourceHandle')
|
||||
Reflect.deleteProperty(e2, 'sourceHandle')
|
||||
|
||||
const result = await getLayoutByDagre(nodes, [e1, e2])
|
||||
expect(result.nodes.size).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle collectLayout with null x/y/width/height values', async () => {
|
||||
mockReturnOverride = (graph: ElkGraph) => ({
|
||||
...graph,
|
||||
children: (graph.children || []).map((child: ElkChild) => ({
|
||||
id: child.id,
|
||||
})),
|
||||
})
|
||||
|
||||
const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })]
|
||||
const result = await getLayoutByDagre(nodes, [])
|
||||
const info = result.nodes.get('a')!
|
||||
expect(info.x).toBe(0)
|
||||
expect(info.y).toBe(0)
|
||||
expect(info.width).toBe(244)
|
||||
expect(info.height).toBe(100)
|
||||
})
|
||||
|
||||
it('should parse layer index from layoutOptions', async () => {
|
||||
mockReturnOverride = (graph: ElkGraph) => ({
|
||||
...graph,
|
||||
children: (graph.children || []).map((child: ElkChild, i: number) => ({
|
||||
...child,
|
||||
x: i * 300,
|
||||
y: 0,
|
||||
width: 244,
|
||||
height: 100,
|
||||
layoutOptions: {
|
||||
'org.eclipse.elk.layered.layerIndex': String(i),
|
||||
},
|
||||
})),
|
||||
})
|
||||
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const result = await getLayoutByDagre(nodes, [])
|
||||
expect(result.nodes.get('a')!.layer).toBe(0)
|
||||
expect(result.nodes.get('b')!.layer).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle collectLayout with nested children', async () => {
|
||||
mockReturnOverride = (graph: ElkGraph) => ({
|
||||
...graph,
|
||||
children: [
|
||||
{
|
||||
id: 'parent-node',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 500,
|
||||
height: 400,
|
||||
children: [
|
||||
{ id: 'nested-1', x: 10, y: 10, width: 200, height: 100 },
|
||||
{ id: 'nested-2', x: 10, y: 120, width: 200, height: 100 },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'parent-node', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'nested-1', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'nested-2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const result = await getLayoutByDagre(nodes, [])
|
||||
expect(result.nodes.has('nested-1')).toBe(true)
|
||||
expect(result.nodes.has('nested-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle collectLayout with predicate filtering some children', async () => {
|
||||
mockReturnOverride = (graph: ElkGraph) => ({
|
||||
...graph,
|
||||
children: [
|
||||
{ id: 'visible', x: 0, y: 0, width: 200, height: 100 },
|
||||
{ id: 'also-visible', x: 300, y: 0, width: 200, height: 100 },
|
||||
],
|
||||
})
|
||||
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'visible', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'also-visible', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const result = await getLayoutByDagre(nodes, [])
|
||||
expect(result.nodes.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should sort IfElse edges where case not found in cases array', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'known-case' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b', sourceHandle: 'unknown-case' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'other-unknown' }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
|
||||
expect(ifNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should sort HumanInput edges where action not found in user_actions', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'hi-1',
|
||||
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'known-action' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'hi-1', target: 'b', sourceHandle: 'unknown-action' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: 'another-unknown' }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
|
||||
expect(hiNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle IfElse edges without handles (no sourceHandle)', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const e1 = makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b' })
|
||||
const e2 = makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c' })
|
||||
Reflect.deleteProperty(e1, 'sourceHandle')
|
||||
Reflect.deleteProperty(e2, 'sourceHandle')
|
||||
|
||||
await getLayoutByDagre(nodes, [e1, e2])
|
||||
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
|
||||
expect(ifNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle HumanInput edges without handles', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'hi-1',
|
||||
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const e1 = makeWorkflowEdge({ id: 'e1', source: 'hi-1', target: 'b' })
|
||||
const e2 = makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c' })
|
||||
Reflect.deleteProperty(e1, 'sourceHandle')
|
||||
Reflect.deleteProperty(e2, 'sourceHandle')
|
||||
|
||||
await getLayoutByDagre(nodes, [e1, e2])
|
||||
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
|
||||
expect(hiNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle IfElse with no cases property', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'if-1', data: { type: BlockEnum.IfElse, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b', sourceHandle: 'true' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
|
||||
expect(ifNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle HumanInput with no user_actions property', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'hi-1', data: { type: BlockEnum.HumanInput, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'hi-1', target: 'b', sourceHandle: 'action-1' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
|
||||
expect(hiNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should filter loop internal edges', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'x', target: 'y', data: { isInLoop: true, loop_id: 'loop-1' } }),
|
||||
]
|
||||
|
||||
await getLayoutByDagre(nodes, edges)
|
||||
expect(layoutCallArgs!.edges).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLayoutForChildNodes', () => {
|
||||
it('should return null when no child nodes exist', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'parent', data: { type: BlockEnum.Iteration, title: '', desc: '' } }),
|
||||
]
|
||||
const result = await getLayoutForChildNodes('parent', nodes, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should layout child nodes of an iteration', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'parent', data: { type: BlockEnum.Iteration, title: '', desc: '' } }),
|
||||
makeWorkflowNode({
|
||||
id: 'iter-start',
|
||||
type: CUSTOM_ITERATION_START_NODE,
|
||||
parentId: 'parent',
|
||||
data: { type: BlockEnum.IterationStart, title: '', desc: '' },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'child-1', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'iter-start', target: 'child-1', data: { isInIteration: true, iteration_id: 'parent' } }),
|
||||
]
|
||||
|
||||
const result = await getLayoutForChildNodes('parent', nodes, edges)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.nodes.size).toBe(2)
|
||||
expect(result!.bounds.minX).toBe(0)
|
||||
})
|
||||
|
||||
it('should layout child nodes of a loop', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'loop-p', data: { type: BlockEnum.Loop, title: '', desc: '' } }),
|
||||
makeWorkflowNode({
|
||||
id: 'loop-start',
|
||||
type: CUSTOM_LOOP_START_NODE,
|
||||
parentId: 'loop-p',
|
||||
data: { type: BlockEnum.LoopStart, title: '', desc: '' },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'loop-child', parentId: 'loop-p', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'loop-start', target: 'loop-child', data: { isInLoop: true, loop_id: 'loop-p' } }),
|
||||
]
|
||||
|
||||
const result = await getLayoutForChildNodes('loop-p', nodes, edges)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.nodes.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should only include edges belonging to the parent iteration', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'child-a', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'child-b', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'child-a', target: 'child-b', data: { isInIteration: true, iteration_id: 'parent' } }),
|
||||
makeWorkflowEdge({ source: 'x', target: 'y', data: { isInIteration: true, iteration_id: 'other-parent' } }),
|
||||
]
|
||||
|
||||
await getLayoutForChildNodes('parent', nodes, edges)
|
||||
expect(layoutCallArgs!.edges).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should adjust start node position when x exceeds horizontal padding', async () => {
|
||||
mockReturnOverride = (graph: ElkGraph) => ({
|
||||
...graph,
|
||||
children: (graph.children || []).map((child: ElkChild, i: number) => ({
|
||||
...child,
|
||||
x: 200 + i * 300,
|
||||
y: 50,
|
||||
width: 244,
|
||||
height: 100,
|
||||
})),
|
||||
})
|
||||
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'start',
|
||||
type: CUSTOM_ITERATION_START_NODE,
|
||||
parentId: 'parent',
|
||||
data: { type: BlockEnum.IterationStart, title: '', desc: '' },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
const result = await getLayoutForChildNodes('parent', nodes, [])
|
||||
expect(result).not.toBeNull()
|
||||
const startInfo = result!.nodes.get('start')!
|
||||
expect(startInfo.x).toBeLessThanOrEqual(NODE_LAYOUT_HORIZONTAL_PADDING / 1.5 + 1)
|
||||
})
|
||||
|
||||
it('should not shift when start node x is already within padding', async () => {
|
||||
mockReturnOverride = (graph: ElkGraph) => ({
|
||||
...graph,
|
||||
children: (graph.children || []).map((child: ElkChild, i: number) => ({
|
||||
...child,
|
||||
x: 10 + i * 300,
|
||||
y: 50,
|
||||
width: 244,
|
||||
height: 100,
|
||||
})),
|
||||
})
|
||||
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'start',
|
||||
type: CUSTOM_ITERATION_START_NODE,
|
||||
parentId: 'parent',
|
||||
data: { type: BlockEnum.IterationStart, title: '', desc: '' },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
const result = await getLayoutForChildNodes('parent', nodes, [])
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should handle child nodes identified by data type LoopStart', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'ls', parentId: 'parent', data: { type: BlockEnum.LoopStart, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
const result = await getLayoutForChildNodes('parent', nodes, [])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.nodes.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle child nodes identified by data type IterationStart', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'is', parentId: 'parent', data: { type: BlockEnum.IterationStart, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
const result = await getLayoutForChildNodes('parent', nodes, [])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.nodes.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle no start node in child layout', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'c1', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c2', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
const result = await getLayoutForChildNodes('parent', nodes, [])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.nodes.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should return original layout when bounds are not finite', async () => {
|
||||
mockReturnOverride = (graph: ElkGraph) => ({
|
||||
...graph,
|
||||
children: [],
|
||||
})
|
||||
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'c1', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
const result = await getLayoutForChildNodes('parent', nodes, [])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.bounds).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { BlockClassificationEnum } from '../../block-selector/types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { genNodeMetaData } from '../gen-node-meta-data'
|
||||
|
||||
describe('genNodeMetaData', () => {
|
||||
it('should generate metadata with all required fields', () => {
|
||||
const result = genNodeMetaData({
|
||||
sort: 1,
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM Node',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: BlockClassificationEnum.Default,
|
||||
sort: 1,
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM Node',
|
||||
author: 'Dify',
|
||||
helpLinkUri: BlockEnum.LLM,
|
||||
isRequired: false,
|
||||
isUndeletable: false,
|
||||
isStart: false,
|
||||
isSingleton: false,
|
||||
isTypeFixed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should use custom values when provided', () => {
|
||||
const result = genNodeMetaData({
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
sort: 5,
|
||||
type: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
author: 'Custom',
|
||||
helpLinkUri: 'code',
|
||||
isRequired: true,
|
||||
isUndeletable: true,
|
||||
isStart: true,
|
||||
isSingleton: true,
|
||||
isTypeFixed: true,
|
||||
})
|
||||
|
||||
expect(result.classification).toBe(BlockClassificationEnum.Logic)
|
||||
expect(result.author).toBe('Custom')
|
||||
expect(result.helpLinkUri).toBe('code')
|
||||
expect(result.isRequired).toBe(true)
|
||||
expect(result.isUndeletable).toBe(true)
|
||||
expect(result.isStart).toBe(true)
|
||||
expect(result.isSingleton).toBe(true)
|
||||
expect(result.isTypeFixed).toBe(true)
|
||||
})
|
||||
|
||||
it('should default title to empty string', () => {
|
||||
const result = genNodeMetaData({
|
||||
sort: 0,
|
||||
type: BlockEnum.Code,
|
||||
})
|
||||
|
||||
expect(result.title).toBe('')
|
||||
})
|
||||
|
||||
it('should fall back helpLinkUri to type when not provided', () => {
|
||||
const result = genNodeMetaData({
|
||||
sort: 0,
|
||||
type: BlockEnum.HttpRequest,
|
||||
})
|
||||
|
||||
expect(result.helpLinkUri).toBe(BlockEnum.HttpRequest)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
scrollToWorkflowNode,
|
||||
selectWorkflowNode,
|
||||
setupNodeSelectionListener,
|
||||
setupScrollToNodeListener,
|
||||
} from '../node-navigation'
|
||||
|
||||
describe('selectWorkflowNode', () => {
|
||||
it('should dispatch workflow:select-node event with correct detail', () => {
|
||||
const handler = vi.fn()
|
||||
document.addEventListener('workflow:select-node', handler)
|
||||
|
||||
selectWorkflowNode('node-1', true)
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
const event = handler.mock.calls[0][0] as CustomEvent
|
||||
expect(event.detail).toEqual({ nodeId: 'node-1', focus: true })
|
||||
|
||||
document.removeEventListener('workflow:select-node', handler)
|
||||
})
|
||||
|
||||
it('should default focus to false', () => {
|
||||
const handler = vi.fn()
|
||||
document.addEventListener('workflow:select-node', handler)
|
||||
|
||||
selectWorkflowNode('node-2')
|
||||
|
||||
const event = handler.mock.calls[0][0] as CustomEvent
|
||||
expect(event.detail.focus).toBe(false)
|
||||
|
||||
document.removeEventListener('workflow:select-node', handler)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollToWorkflowNode', () => {
|
||||
it('should dispatch workflow:scroll-to-node event', () => {
|
||||
const handler = vi.fn()
|
||||
document.addEventListener('workflow:scroll-to-node', handler)
|
||||
|
||||
scrollToWorkflowNode('node-5')
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
const event = handler.mock.calls[0][0] as CustomEvent
|
||||
expect(event.detail).toEqual({ nodeId: 'node-5' })
|
||||
|
||||
document.removeEventListener('workflow:scroll-to-node', handler)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupNodeSelectionListener', () => {
|
||||
it('should call handleNodeSelect when event is dispatched', () => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
const cleanup = setupNodeSelectionListener(handleNodeSelect)
|
||||
|
||||
selectWorkflowNode('node-10')
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-10')
|
||||
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('should also scroll to node when focus is true', () => {
|
||||
vi.useFakeTimers()
|
||||
const handleNodeSelect = vi.fn()
|
||||
const scrollHandler = vi.fn()
|
||||
document.addEventListener('workflow:scroll-to-node', scrollHandler)
|
||||
|
||||
const cleanup = setupNodeSelectionListener(handleNodeSelect)
|
||||
selectWorkflowNode('node-11', true)
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-11')
|
||||
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(scrollHandler).toHaveBeenCalledTimes(1)
|
||||
|
||||
cleanup()
|
||||
document.removeEventListener('workflow:scroll-to-node', scrollHandler)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not call handler after cleanup', () => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
const cleanup = setupNodeSelectionListener(handleNodeSelect)
|
||||
|
||||
cleanup()
|
||||
selectWorkflowNode('node-12')
|
||||
|
||||
expect(handleNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore events with empty nodeId', () => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
const cleanup = setupNodeSelectionListener(handleNodeSelect)
|
||||
|
||||
const event = new CustomEvent('workflow:select-node', {
|
||||
detail: { nodeId: '', focus: false },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(handleNodeSelect).not.toHaveBeenCalled()
|
||||
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupScrollToNodeListener', () => {
|
||||
it('should call reactflow.setCenter when scroll event targets an existing node', () => {
|
||||
const nodes = [{ id: 'n1', position: { x: 100, y: 200 } }]
|
||||
const reactflow = { setCenter: vi.fn() }
|
||||
|
||||
const cleanup = setupScrollToNodeListener(nodes, reactflow)
|
||||
scrollToWorkflowNode('n1')
|
||||
|
||||
expect(reactflow.setCenter).toHaveBeenCalledTimes(1)
|
||||
const [targetX, targetY, options] = reactflow.setCenter.mock.calls[0]
|
||||
expect(targetX).toBeGreaterThan(100)
|
||||
expect(targetY).toBeGreaterThan(200)
|
||||
expect(options).toEqual({ zoom: 1, duration: 800 })
|
||||
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('should not call setCenter when node is not found', () => {
|
||||
const nodes = [{ id: 'n1', position: { x: 0, y: 0 } }]
|
||||
const reactflow = { setCenter: vi.fn() }
|
||||
|
||||
const cleanup = setupScrollToNodeListener(nodes, reactflow)
|
||||
scrollToWorkflowNode('non-existent')
|
||||
|
||||
expect(reactflow.setCenter).not.toHaveBeenCalled()
|
||||
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('should not react after cleanup', () => {
|
||||
const nodes = [{ id: 'n1', position: { x: 0, y: 0 } }]
|
||||
const reactflow = { setCenter: vi.fn() }
|
||||
|
||||
const cleanup = setupScrollToNodeListener(nodes, reactflow)
|
||||
cleanup()
|
||||
|
||||
scrollToWorkflowNode('n1')
|
||||
expect(reactflow.setCenter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore events with empty nodeId', () => {
|
||||
const nodes = [{ id: 'n1', position: { x: 0, y: 0 } }]
|
||||
const reactflow = { setCenter: vi.fn() }
|
||||
|
||||
const cleanup = setupScrollToNodeListener(nodes, reactflow)
|
||||
|
||||
const event = new CustomEvent('workflow:scroll-to-node', {
|
||||
detail: { nodeId: '' },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(reactflow.setCenter).not.toHaveBeenCalled()
|
||||
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
import type { IterationNodeType } from '../../nodes/iteration/types'
|
||||
import type { LoopNodeType } from '../../nodes/loop/types'
|
||||
import type { CommonNodeType, Node } from '../../types'
|
||||
import { CUSTOM_NODE, ITERATION_CHILDREN_Z_INDEX, ITERATION_NODE_Z_INDEX, LOOP_CHILDREN_Z_INDEX, LOOP_NODE_Z_INDEX } from '../../constants'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '../../nodes/loop-start/constants'
|
||||
import { CUSTOM_SIMPLE_NODE } from '../../simple-node/constants'
|
||||
import { BlockEnum } from '../../types'
|
||||
import {
|
||||
generateNewNode,
|
||||
genNewNodeTitleFromOld,
|
||||
getIterationStartNode,
|
||||
getLoopStartNode,
|
||||
getNestedNodePosition,
|
||||
getNodeCustomTypeByNodeDataType,
|
||||
getTopLeftNodePosition,
|
||||
hasRetryNode,
|
||||
} from '../node'
|
||||
|
||||
describe('generateNewNode', () => {
|
||||
it('should create a basic node with default CUSTOM_NODE type', () => {
|
||||
const { newNode } = generateNewNode({
|
||||
data: { title: 'Test', desc: '', type: BlockEnum.Code } as CommonNodeType,
|
||||
position: { x: 100, y: 200 },
|
||||
})
|
||||
|
||||
expect(newNode.type).toBe(CUSTOM_NODE)
|
||||
expect(newNode.position).toEqual({ x: 100, y: 200 })
|
||||
expect(newNode.data.title).toBe('Test')
|
||||
expect(newNode.id).toBeDefined()
|
||||
})
|
||||
|
||||
it('should use provided id when given', () => {
|
||||
const { newNode } = generateNewNode({
|
||||
id: 'custom-id',
|
||||
data: { title: 'Test', desc: '', type: BlockEnum.Code } as CommonNodeType,
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
expect(newNode.id).toBe('custom-id')
|
||||
})
|
||||
|
||||
it('should set ITERATION_NODE_Z_INDEX for iteration nodes', () => {
|
||||
const { newNode } = generateNewNode({
|
||||
data: { title: 'Iter', desc: '', type: BlockEnum.Iteration } as CommonNodeType,
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
expect(newNode.zIndex).toBe(ITERATION_NODE_Z_INDEX)
|
||||
})
|
||||
|
||||
it('should set LOOP_NODE_Z_INDEX for loop nodes', () => {
|
||||
const { newNode } = generateNewNode({
|
||||
data: { title: 'Loop', desc: '', type: BlockEnum.Loop } as CommonNodeType,
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
expect(newNode.zIndex).toBe(LOOP_NODE_Z_INDEX)
|
||||
})
|
||||
|
||||
it('should create an iteration start node for iteration type', () => {
|
||||
const { newNode, newIterationStartNode } = generateNewNode({
|
||||
id: 'iter-1',
|
||||
data: { title: 'Iter', desc: '', type: BlockEnum.Iteration } as CommonNodeType,
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
expect(newIterationStartNode).toBeDefined()
|
||||
expect(newIterationStartNode!.id).toBe('iter-1start')
|
||||
expect(newIterationStartNode!.data.type).toBe(BlockEnum.IterationStart)
|
||||
expect((newNode.data as IterationNodeType).start_node_id).toBe('iter-1start')
|
||||
expect((newNode.data as CommonNodeType)._children).toEqual([
|
||||
{ nodeId: 'iter-1start', nodeType: BlockEnum.IterationStart },
|
||||
])
|
||||
})
|
||||
|
||||
it('should create a loop start node for loop type', () => {
|
||||
const { newNode, newLoopStartNode } = generateNewNode({
|
||||
id: 'loop-1',
|
||||
data: { title: 'Loop', desc: '', type: BlockEnum.Loop } as CommonNodeType,
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
expect(newLoopStartNode).toBeDefined()
|
||||
expect(newLoopStartNode!.id).toBe('loop-1start')
|
||||
expect(newLoopStartNode!.data.type).toBe(BlockEnum.LoopStart)
|
||||
expect((newNode.data as LoopNodeType).start_node_id).toBe('loop-1start')
|
||||
expect((newNode.data as CommonNodeType)._children).toEqual([
|
||||
{ nodeId: 'loop-1start', nodeType: BlockEnum.LoopStart },
|
||||
])
|
||||
})
|
||||
|
||||
it('should not create child start nodes for regular types', () => {
|
||||
const result = generateNewNode({
|
||||
data: { title: 'Code', desc: '', type: BlockEnum.Code } as CommonNodeType,
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
expect(result.newIterationStartNode).toBeUndefined()
|
||||
expect(result.newLoopStartNode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIterationStartNode', () => {
|
||||
it('should create a properly configured iteration start node', () => {
|
||||
const node = getIterationStartNode('parent-iter')
|
||||
|
||||
expect(node.id).toBe('parent-iterstart')
|
||||
expect(node.type).toBe(CUSTOM_ITERATION_START_NODE)
|
||||
expect(node.data.type).toBe(BlockEnum.IterationStart)
|
||||
expect(node.data.isInIteration).toBe(true)
|
||||
expect(node.parentId).toBe('parent-iter')
|
||||
expect(node.selectable).toBe(false)
|
||||
expect(node.draggable).toBe(false)
|
||||
expect(node.zIndex).toBe(ITERATION_CHILDREN_Z_INDEX)
|
||||
expect(node.position).toEqual({ x: 24, y: 68 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLoopStartNode', () => {
|
||||
it('should create a properly configured loop start node', () => {
|
||||
const node = getLoopStartNode('parent-loop')
|
||||
|
||||
expect(node.id).toBe('parent-loopstart')
|
||||
expect(node.type).toBe(CUSTOM_LOOP_START_NODE)
|
||||
expect(node.data.type).toBe(BlockEnum.LoopStart)
|
||||
expect(node.data.isInLoop).toBe(true)
|
||||
expect(node.parentId).toBe('parent-loop')
|
||||
expect(node.selectable).toBe(false)
|
||||
expect(node.draggable).toBe(false)
|
||||
expect(node.zIndex).toBe(LOOP_CHILDREN_Z_INDEX)
|
||||
expect(node.position).toEqual({ x: 24, y: 68 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('genNewNodeTitleFromOld', () => {
|
||||
it('should append (1) to a title without a counter', () => {
|
||||
expect(genNewNodeTitleFromOld('LLM')).toBe('LLM (1)')
|
||||
})
|
||||
|
||||
it('should increment existing counter', () => {
|
||||
expect(genNewNodeTitleFromOld('LLM (1)')).toBe('LLM (2)')
|
||||
expect(genNewNodeTitleFromOld('LLM (99)')).toBe('LLM (100)')
|
||||
})
|
||||
|
||||
it('should handle titles with spaces around counter', () => {
|
||||
expect(genNewNodeTitleFromOld('My Node (3)')).toBe('My Node (4)')
|
||||
})
|
||||
|
||||
it('should handle titles that happen to contain parentheses in the name', () => {
|
||||
expect(genNewNodeTitleFromOld('Node (special) name')).toBe('Node (special) name (1)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTopLeftNodePosition', () => {
|
||||
it('should return the minimum x and y from nodes', () => {
|
||||
const nodes = [
|
||||
{ position: { x: 100, y: 50 } },
|
||||
{ position: { x: 20, y: 200 } },
|
||||
{ position: { x: 50, y: 10 } },
|
||||
] as Node[]
|
||||
|
||||
expect(getTopLeftNodePosition(nodes)).toEqual({ x: 20, y: 10 })
|
||||
})
|
||||
|
||||
it('should handle a single node', () => {
|
||||
const nodes = [{ position: { x: 42, y: 99 } }] as Node[]
|
||||
expect(getTopLeftNodePosition(nodes)).toEqual({ x: 42, y: 99 })
|
||||
})
|
||||
|
||||
it('should handle negative positions', () => {
|
||||
const nodes = [
|
||||
{ position: { x: -10, y: -20 } },
|
||||
{ position: { x: 5, y: -30 } },
|
||||
] as Node[]
|
||||
|
||||
expect(getTopLeftNodePosition(nodes)).toEqual({ x: -10, y: -30 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNestedNodePosition', () => {
|
||||
it('should compute relative position of child to parent', () => {
|
||||
const node = { position: { x: 150, y: 200 } } as Node
|
||||
const parent = { position: { x: 100, y: 80 } } as Node
|
||||
|
||||
expect(getNestedNodePosition(node, parent)).toEqual({ x: 50, y: 120 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasRetryNode', () => {
|
||||
it.each([BlockEnum.LLM, BlockEnum.Tool, BlockEnum.HttpRequest, BlockEnum.Code])(
|
||||
'should return true for %s',
|
||||
(nodeType) => {
|
||||
expect(hasRetryNode(nodeType)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([BlockEnum.Start, BlockEnum.End, BlockEnum.IfElse, BlockEnum.Iteration])(
|
||||
'should return false for %s',
|
||||
(nodeType) => {
|
||||
expect(hasRetryNode(nodeType)).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('should return false when nodeType is undefined', () => {
|
||||
expect(hasRetryNode()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeCustomTypeByNodeDataType', () => {
|
||||
it('should return CUSTOM_SIMPLE_NODE for LoopEnd', () => {
|
||||
expect(getNodeCustomTypeByNodeDataType(BlockEnum.LoopEnd)).toBe(CUSTOM_SIMPLE_NODE)
|
||||
})
|
||||
|
||||
it('should return undefined for other types', () => {
|
||||
expect(getNodeCustomTypeByNodeDataType(BlockEnum.Code)).toBeUndefined()
|
||||
expect(getNodeCustomTypeByNodeDataType(BlockEnum.LLM)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import type { ToolNodeType } from '../../nodes/tool/types'
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { CHUNK_TYPE_MAP, getToolCheckParams, wrapStructuredVarItem } from '../tool'
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
toolParametersToFormSchemas: vi.fn((params: Array<Record<string, unknown>>) =>
|
||||
params.map(p => ({
|
||||
variable: p.name,
|
||||
label: p.label || { en_US: p.name },
|
||||
type: p.type || 'string',
|
||||
required: p.required ?? false,
|
||||
form: p.form ?? 'llm',
|
||||
}))),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
canFindTool: vi.fn((collectionId: string, providerId: string) => collectionId === providerId),
|
||||
}))
|
||||
|
||||
function createToolData(overrides: Partial<ToolNodeType> = {}): ToolNodeType {
|
||||
return {
|
||||
title: 'Tool',
|
||||
desc: '',
|
||||
type: BlockEnum.Tool,
|
||||
provider_id: 'builtin-search',
|
||||
provider_type: CollectionType.builtIn,
|
||||
tool_name: 'google_search',
|
||||
tool_parameters: {},
|
||||
tool_configurations: {},
|
||||
...overrides,
|
||||
} as ToolNodeType
|
||||
}
|
||||
|
||||
function createToolCollection(overrides: Partial<ToolWithProvider> = {}): ToolWithProvider {
|
||||
return {
|
||||
id: 'builtin-search',
|
||||
name: 'Search',
|
||||
tools: [
|
||||
{
|
||||
name: 'google_search',
|
||||
parameters: [
|
||||
{ name: 'query', label: { en_US: 'Query', zh_Hans: '查询' }, type: 'string', required: true, form: 'llm' },
|
||||
{ name: 'api_key', label: { en_US: 'API Key' }, type: 'string', required: true, form: 'credential' },
|
||||
],
|
||||
},
|
||||
],
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
...overrides,
|
||||
} as unknown as ToolWithProvider
|
||||
}
|
||||
|
||||
describe('getToolCheckParams', () => {
|
||||
it('should separate llm inputs from settings', () => {
|
||||
const result = getToolCheckParams(
|
||||
createToolData(),
|
||||
[createToolCollection()],
|
||||
[],
|
||||
[],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.toolInputsSchema).toEqual([
|
||||
{ label: 'Query', variable: 'query', type: 'string', required: true },
|
||||
])
|
||||
expect(result.toolSettingSchema).toHaveLength(1)
|
||||
expect(result.toolSettingSchema[0].variable).toBe('api_key')
|
||||
})
|
||||
|
||||
it('should mark notAuthed for builtin tools without team auth', () => {
|
||||
const result = getToolCheckParams(
|
||||
createToolData(),
|
||||
[createToolCollection()],
|
||||
[],
|
||||
[],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.notAuthed).toBe(true)
|
||||
})
|
||||
|
||||
it('should mark authed when is_team_authorization is true', () => {
|
||||
const result = getToolCheckParams(
|
||||
createToolData(),
|
||||
[createToolCollection({ is_team_authorization: true })],
|
||||
[],
|
||||
[],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.notAuthed).toBe(false)
|
||||
})
|
||||
|
||||
it('should use custom tools when provider_type is custom', () => {
|
||||
const customTool = createToolCollection({ id: 'custom-tool' })
|
||||
const result = getToolCheckParams(
|
||||
createToolData({ provider_id: 'custom-tool', provider_type: CollectionType.custom }),
|
||||
[],
|
||||
[customTool],
|
||||
[],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.toolInputsSchema).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should return empty schemas when tool is not found', () => {
|
||||
const result = getToolCheckParams(
|
||||
createToolData({ provider_id: 'non-existent' }),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.toolInputsSchema).toEqual([])
|
||||
expect(result.toolSettingSchema).toEqual([])
|
||||
})
|
||||
|
||||
it('should include language in result', () => {
|
||||
const result = getToolCheckParams(createToolData(), [createToolCollection()], [], [], 'zh_Hans')
|
||||
expect(result.language).toBe('zh_Hans')
|
||||
})
|
||||
|
||||
it('should use workflowTools when provider_type is workflow', () => {
|
||||
const workflowTool = createToolCollection({ id: 'wf-tool' })
|
||||
const result = getToolCheckParams(
|
||||
createToolData({ provider_id: 'wf-tool', provider_type: CollectionType.workflow }),
|
||||
[],
|
||||
[],
|
||||
[workflowTool],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.toolInputsSchema).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should fallback to en_US label when language key is missing', () => {
|
||||
const tool = createToolCollection({
|
||||
tools: [
|
||||
{
|
||||
name: 'google_search',
|
||||
parameters: [
|
||||
{ name: 'query', label: { en_US: 'Query' }, type: 'string', required: true, form: 'llm' },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<ToolWithProvider>)
|
||||
|
||||
const result = getToolCheckParams(
|
||||
createToolData(),
|
||||
[tool],
|
||||
[],
|
||||
[],
|
||||
'ja_JP',
|
||||
)
|
||||
|
||||
expect(result.toolInputsSchema[0].label).toBe('Query')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CHUNK_TYPE_MAP', () => {
|
||||
it('should contain all expected chunk type mappings', () => {
|
||||
expect(CHUNK_TYPE_MAP).toEqual({
|
||||
general_chunks: 'GeneralStructureChunk',
|
||||
parent_child_chunks: 'ParentChildStructureChunk',
|
||||
qa_chunks: 'QAStructureChunk',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapStructuredVarItem', () => {
|
||||
it('should wrap an output item into StructuredOutput format', () => {
|
||||
const outputItem = {
|
||||
name: 'result',
|
||||
value: { type: 'string', description: 'test' },
|
||||
}
|
||||
|
||||
const result = wrapStructuredVarItem(outputItem, 'json_schema')
|
||||
|
||||
expect(result.schema.type).toBe('object')
|
||||
expect(result.schema.additionalProperties).toBe(false)
|
||||
expect(result.schema.properties.result).toEqual({
|
||||
type: 'string',
|
||||
description: 'test',
|
||||
schemaType: 'json_schema',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import type { TriggerWithProvider } from '../../block-selector/types'
|
||||
import type { PluginTriggerNodeType } from '../../nodes/trigger-plugin/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { getTriggerCheckParams } from '../trigger'
|
||||
|
||||
function createTriggerData(overrides: Partial<PluginTriggerNodeType> = {}): PluginTriggerNodeType {
|
||||
return {
|
||||
title: 'Trigger',
|
||||
desc: '',
|
||||
type: BlockEnum.TriggerPlugin,
|
||||
provider_id: 'provider-1',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_name: 'my-provider',
|
||||
event_name: 'on_message',
|
||||
event_label: 'On Message',
|
||||
event_parameters: {},
|
||||
event_configurations: {},
|
||||
output_schema: {},
|
||||
...overrides,
|
||||
} as PluginTriggerNodeType
|
||||
}
|
||||
|
||||
function createTriggerProvider(overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider {
|
||||
return {
|
||||
id: 'provider-1',
|
||||
name: 'my-provider',
|
||||
plugin_id: 'plugin-1',
|
||||
events: [
|
||||
{
|
||||
name: 'on_message',
|
||||
label: { en_US: 'On Message', zh_Hans: '收到消息' },
|
||||
parameters: [
|
||||
{
|
||||
name: 'channel',
|
||||
label: { en_US: 'Channel', zh_Hans: '频道' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'filter',
|
||||
label: { en_US: 'Filter' },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as unknown as TriggerWithProvider
|
||||
}
|
||||
|
||||
describe('getTriggerCheckParams', () => {
|
||||
it('should return empty schema when triggerProviders is undefined', () => {
|
||||
const result = getTriggerCheckParams(createTriggerData(), undefined, 'en_US')
|
||||
|
||||
expect(result).toEqual({
|
||||
triggerInputsSchema: [],
|
||||
isReadyForCheckValid: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should match provider by name and extract parameters', () => {
|
||||
const result = getTriggerCheckParams(
|
||||
createTriggerData(),
|
||||
[createTriggerProvider()],
|
||||
'en_US',
|
||||
)
|
||||
|
||||
expect(result.isReadyForCheckValid).toBe(true)
|
||||
expect(result.triggerInputsSchema).toEqual([
|
||||
{ variable: 'channel', label: 'Channel', required: true },
|
||||
{ variable: 'filter', label: 'Filter', required: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('should use the requested language for labels', () => {
|
||||
const result = getTriggerCheckParams(
|
||||
createTriggerData(),
|
||||
[createTriggerProvider()],
|
||||
'zh_Hans',
|
||||
)
|
||||
|
||||
expect(result.triggerInputsSchema[0].label).toBe('频道')
|
||||
})
|
||||
|
||||
it('should fall back to en_US when language label is missing', () => {
|
||||
const result = getTriggerCheckParams(
|
||||
createTriggerData(),
|
||||
[createTriggerProvider()],
|
||||
'ja_JP',
|
||||
)
|
||||
|
||||
expect(result.triggerInputsSchema[0].label).toBe('Channel')
|
||||
})
|
||||
|
||||
it('should fall back to parameter name when no labels exist', () => {
|
||||
const provider = createTriggerProvider({
|
||||
events: [{
|
||||
name: 'on_message',
|
||||
label: { en_US: 'On Message' },
|
||||
parameters: [{ name: 'raw_param' }],
|
||||
}],
|
||||
} as Partial<TriggerWithProvider>)
|
||||
|
||||
const result = getTriggerCheckParams(createTriggerData(), [provider], 'en_US')
|
||||
|
||||
expect(result.triggerInputsSchema[0].label).toBe('raw_param')
|
||||
})
|
||||
|
||||
it('should match provider by provider_id', () => {
|
||||
const trigger = createTriggerData({ provider_name: 'different-name', provider_id: 'provider-1' })
|
||||
const provider = createTriggerProvider({ name: 'other-name', id: 'provider-1' })
|
||||
|
||||
const result = getTriggerCheckParams(trigger, [provider], 'en_US')
|
||||
expect(result.isReadyForCheckValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should match provider by plugin_id', () => {
|
||||
const trigger = createTriggerData({ provider_name: 'x', provider_id: 'plugin-1' })
|
||||
const provider = createTriggerProvider({ name: 'y', id: 'z', plugin_id: 'plugin-1' })
|
||||
|
||||
const result = getTriggerCheckParams(trigger, [provider], 'en_US')
|
||||
expect(result.isReadyForCheckValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should return empty schema when event is not found', () => {
|
||||
const trigger = createTriggerData({ event_name: 'non_existent_event' })
|
||||
|
||||
const result = getTriggerCheckParams(trigger, [createTriggerProvider()], 'en_US')
|
||||
expect(result.triggerInputsSchema).toEqual([])
|
||||
expect(result.isReadyForCheckValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { BlockEnum } from '../../types'
|
||||
import { isExceptionVariable, variableTransformer } from '../variable'
|
||||
|
||||
describe('variableTransformer', () => {
|
||||
describe('string → array (template to selector)', () => {
|
||||
it('should parse a simple template variable', () => {
|
||||
expect(variableTransformer('{{#node1.output#}}')).toEqual(['node1', 'output'])
|
||||
})
|
||||
|
||||
it('should parse a deeply nested path', () => {
|
||||
expect(variableTransformer('{{#node1.data.items.0.name#}}')).toEqual(['node1', 'data', 'items', '0', 'name'])
|
||||
})
|
||||
|
||||
it('should handle a single-segment path', () => {
|
||||
expect(variableTransformer('{{#value#}}')).toEqual(['value'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('array → string (selector to template)', () => {
|
||||
it('should join an array into a template variable', () => {
|
||||
expect(variableTransformer(['node1', 'output'])).toBe('{{#node1.output#}}')
|
||||
})
|
||||
|
||||
it('should join a single-element array', () => {
|
||||
expect(variableTransformer(['value'])).toBe('{{#value#}}')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExceptionVariable', () => {
|
||||
const errorHandleTypes = [BlockEnum.LLM, BlockEnum.Tool, BlockEnum.HttpRequest, BlockEnum.Code, BlockEnum.Agent]
|
||||
|
||||
it.each(errorHandleTypes)('should return true for error_message with %s node type', (nodeType) => {
|
||||
expect(isExceptionVariable('error_message', nodeType)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(errorHandleTypes)('should return true for error_type with %s node type', (nodeType) => {
|
||||
expect(isExceptionVariable('error_type', nodeType)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for error_message with non-error-handle node types', () => {
|
||||
expect(isExceptionVariable('error_message', BlockEnum.Start)).toBe(false)
|
||||
expect(isExceptionVariable('error_message', BlockEnum.End)).toBe(false)
|
||||
expect(isExceptionVariable('error_message', BlockEnum.IfElse)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for normal variables with error-handle node types', () => {
|
||||
expect(isExceptionVariable('output', BlockEnum.LLM)).toBe(false)
|
||||
expect(isExceptionVariable('text', BlockEnum.Tool)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when nodeType is undefined', () => {
|
||||
expect(isExceptionVariable('error_message')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { createNode, createStartNode, createTriggerNode, resetFixtureCounters } from '../../__tests__/fixtures'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { getWorkflowEntryNode, isTriggerWorkflow, isWorkflowEntryNode } from '../workflow-entry'
|
||||
|
||||
beforeEach(() => {
|
||||
resetFixtureCounters()
|
||||
})
|
||||
|
||||
describe('getWorkflowEntryNode', () => {
|
||||
it('should return the trigger node when present', () => {
|
||||
const nodes = [
|
||||
createStartNode({ id: 'start' }),
|
||||
createTriggerNode(BlockEnum.TriggerWebhook, { id: 'trigger' }),
|
||||
createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
const entry = getWorkflowEntryNode(nodes)
|
||||
expect(entry?.id).toBe('trigger')
|
||||
})
|
||||
|
||||
it('should return the start node when no trigger node exists', () => {
|
||||
const nodes = [
|
||||
createStartNode({ id: 'start' }),
|
||||
createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
const entry = getWorkflowEntryNode(nodes)
|
||||
expect(entry?.id).toBe('start')
|
||||
})
|
||||
|
||||
it('should return undefined when no entry node exists', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
|
||||
expect(getWorkflowEntryNode(nodes)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should prefer trigger node over start node', () => {
|
||||
const nodes = [
|
||||
createStartNode({ id: 'start' }),
|
||||
createTriggerNode(BlockEnum.TriggerSchedule, { id: 'schedule' }),
|
||||
]
|
||||
|
||||
const entry = getWorkflowEntryNode(nodes)
|
||||
expect(entry?.id).toBe('schedule')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWorkflowEntryNode', () => {
|
||||
it('should return true for Start', () => {
|
||||
expect(isWorkflowEntryNode(BlockEnum.Start)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin])(
|
||||
'should return true for %s',
|
||||
(type) => {
|
||||
expect(isWorkflowEntryNode(type)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it('should return false for non-entry types', () => {
|
||||
expect(isWorkflowEntryNode(BlockEnum.Code)).toBe(false)
|
||||
expect(isWorkflowEntryNode(BlockEnum.LLM)).toBe(false)
|
||||
expect(isWorkflowEntryNode(BlockEnum.End)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTriggerWorkflow', () => {
|
||||
it('should return true when nodes contain a trigger node', () => {
|
||||
const nodes = [
|
||||
createStartNode(),
|
||||
createTriggerNode(BlockEnum.TriggerWebhook),
|
||||
]
|
||||
expect(isTriggerWorkflow(nodes)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when no trigger nodes exist', () => {
|
||||
const nodes = [
|
||||
createStartNode(),
|
||||
createNode({ data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
expect(isTriggerWorkflow(nodes)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty nodes', () => {
|
||||
expect(isTriggerWorkflow([])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,742 @@
|
|||
import type { IfElseNodeType } from '../../nodes/if-else/types'
|
||||
import type { IterationNodeType } from '../../nodes/iteration/types'
|
||||
import type { KnowledgeRetrievalNodeType } from '../../nodes/knowledge-retrieval/types'
|
||||
import type { LLMNodeType } from '../../nodes/llm/types'
|
||||
import type { LoopNodeType } from '../../nodes/loop/types'
|
||||
import type { ParameterExtractorNodeType } from '../../nodes/parameter-extractor/types'
|
||||
import type { ToolNodeType } from '../../nodes/tool/types'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { CUSTOM_NODE, DEFAULT_RETRY_INTERVAL, DEFAULT_RETRY_MAX } from '@/app/components/workflow/constants'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
|
||||
import { BlockEnum, ErrorHandleMode } from '@/app/components/workflow/types'
|
||||
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
|
||||
import { initialEdges, initialNodes, preprocessNodesAndEdges } from '../workflow-init'
|
||||
|
||||
vi.mock('reactflow', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('reactflow')>()
|
||||
return {
|
||||
...actual,
|
||||
getConnectedEdges: vi.fn((_nodes: Node[], edges: Edge[]) => {
|
||||
const node = _nodes[0]
|
||||
return edges.filter(e => e.source === node.id || e.target === node.id)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
correctModelProvider: vi.fn((p: string) => p ? `corrected/${p}` : ''),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/if-else/utils', () => ({
|
||||
branchNameCorrect: vi.fn((branches: Array<Record<string, unknown>>) => branches.map((b: Record<string, unknown>, i: number) => ({
|
||||
...b,
|
||||
name: b.id === 'false' ? 'ELSE' : branches.length === 2 ? 'IF' : `CASE ${i + 1}`,
|
||||
}))),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
resetFixtureCounters()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('preprocessNodesAndEdges', () => {
|
||||
it('should return origin nodes and edges when no iteration/loop nodes exist', () => {
|
||||
const nodes = [createNode({ data: { type: BlockEnum.Code, title: '', desc: '' } })]
|
||||
const result = preprocessNodesAndEdges(nodes, [])
|
||||
expect(result).toEqual({ nodes, edges: [] })
|
||||
})
|
||||
|
||||
it('should add iteration start node when iteration has no start_node_id', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'iter-1', data: { type: BlockEnum.Iteration, title: '', desc: '' } }),
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.IterationStart)
|
||||
expect(startNodes).toHaveLength(1)
|
||||
expect(startNodes[0].parentId).toBe('iter-1')
|
||||
})
|
||||
|
||||
it('should add iteration start node when iteration has start_node_id but node type does not match', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'iter-1',
|
||||
data: { type: BlockEnum.Iteration, title: '', desc: '', start_node_id: 'some-node' },
|
||||
}),
|
||||
createNode({ id: 'some-node', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.IterationStart)
|
||||
expect(startNodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should not add iteration start node when one already exists with correct type', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'iter-1',
|
||||
data: { type: BlockEnum.Iteration, title: '', desc: '', start_node_id: 'iter-start' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'iter-start',
|
||||
type: CUSTOM_ITERATION_START_NODE,
|
||||
data: { type: BlockEnum.IterationStart, title: '', desc: '' },
|
||||
}),
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
expect(result.nodes).toEqual(nodes)
|
||||
})
|
||||
|
||||
it('should add loop start node when loop has no start_node_id', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'loop-1', data: { type: BlockEnum.Loop, title: '', desc: '' } }),
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.LoopStart)
|
||||
expect(startNodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should add loop start node when loop has start_node_id but type does not match', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'loop-1',
|
||||
data: { type: BlockEnum.Loop, title: '', desc: '', start_node_id: 'some-node' },
|
||||
}),
|
||||
createNode({ id: 'some-node', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.LoopStart)
|
||||
expect(startNodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should not add loop start node when one already exists with correct type', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'loop-1',
|
||||
data: { type: BlockEnum.Loop, title: '', desc: '', start_node_id: 'loop-start' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'loop-start',
|
||||
type: CUSTOM_LOOP_START_NODE,
|
||||
data: { type: BlockEnum.LoopStart, title: '', desc: '' },
|
||||
}),
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
expect(result.nodes).toEqual(nodes)
|
||||
})
|
||||
|
||||
it('should create edges linking new start nodes to existing start nodes', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'iter-1',
|
||||
data: { type: BlockEnum.Iteration, title: '', desc: '', start_node_id: 'child-1' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'child-1',
|
||||
parentId: 'iter-1',
|
||||
data: { type: BlockEnum.Code, title: '', desc: '' },
|
||||
}),
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
const newEdges = result.edges
|
||||
expect(newEdges).toHaveLength(1)
|
||||
expect(newEdges[0].target).toBe('child-1')
|
||||
expect(newEdges[0].data!.sourceType).toBe(BlockEnum.IterationStart)
|
||||
expect(newEdges[0].data!.isInIteration).toBe(true)
|
||||
})
|
||||
|
||||
it('should create edges for loop nodes with start_node_id', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'loop-1',
|
||||
data: { type: BlockEnum.Loop, title: '', desc: '', start_node_id: 'child-1' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'child-1',
|
||||
parentId: 'loop-1',
|
||||
data: { type: BlockEnum.Code, title: '', desc: '' },
|
||||
}),
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
const newEdges = result.edges
|
||||
expect(newEdges).toHaveLength(1)
|
||||
expect(newEdges[0].target).toBe('child-1')
|
||||
expect(newEdges[0].data!.isInLoop).toBe(true)
|
||||
})
|
||||
|
||||
it('should update start_node_id on iteration and loop nodes', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'iter-1',
|
||||
data: { type: BlockEnum.Iteration, title: '', desc: '' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'loop-1',
|
||||
data: { type: BlockEnum.Loop, title: '', desc: '' },
|
||||
}),
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
const iterNode = result.nodes.find(n => n.id === 'iter-1')
|
||||
const loopNode = result.nodes.find(n => n.id === 'loop-1')
|
||||
expect((iterNode!.data as IterationNodeType).start_node_id).toBeTruthy()
|
||||
expect((loopNode!.data as LoopNodeType).start_node_id).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialNodes', () => {
|
||||
it('should set positions when first node has no position', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'n2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
nodes.forEach(n => Reflect.deleteProperty(n, 'position'))
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect(result[0].position).toBeDefined()
|
||||
expect(result[1].position).toBeDefined()
|
||||
expect(result[1].position.x).toBeGreaterThan(result[0].position.x)
|
||||
})
|
||||
|
||||
it('should set type to CUSTOM_NODE when type is missing', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
]
|
||||
Reflect.deleteProperty(nodes[0], 'type')
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect(result[0].type).toBe(CUSTOM_NODE)
|
||||
})
|
||||
|
||||
it('should set connected source and target handle ids', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'a', target: 'b', sourceHandle: 'source', targetHandle: 'target' }),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, edges)
|
||||
expect(result[0].data._connectedSourceHandleIds).toContain('source')
|
||||
expect(result[1].data._connectedTargetHandleIds).toContain('target')
|
||||
})
|
||||
|
||||
it('should handle IfElse node with cases', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'if-1',
|
||||
data: {
|
||||
type: BlockEnum.IfElse,
|
||||
title: '',
|
||||
desc: '',
|
||||
cases: [
|
||||
{ case_id: 'case-1', logical_operator: 'and', conditions: [] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect(result[0].data._targetBranches).toBeDefined()
|
||||
expect(result[0].data._targetBranches).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should migrate legacy IfElse node without cases to cases format', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'if-1',
|
||||
data: {
|
||||
type: BlockEnum.IfElse,
|
||||
title: '',
|
||||
desc: '',
|
||||
logical_operator: 'and',
|
||||
conditions: [{ id: 'c1', value: 'test' }],
|
||||
cases: undefined,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const data = result[0].data as IfElseNodeType
|
||||
expect(data.cases).toHaveLength(1)
|
||||
expect(data.cases[0].case_id).toBe('true')
|
||||
})
|
||||
|
||||
it('should delete legacy conditions/logical_operator when cases exist', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'if-1',
|
||||
data: {
|
||||
type: BlockEnum.IfElse,
|
||||
title: '',
|
||||
desc: '',
|
||||
logical_operator: 'and',
|
||||
conditions: [{ id: 'c1', value: 'test' }],
|
||||
cases: [
|
||||
{ case_id: 'true', logical_operator: 'and', conditions: [{ id: 'c1', value: 'test' }] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const data = result[0].data as IfElseNodeType
|
||||
expect(data.conditions).toBeUndefined()
|
||||
expect(data.logical_operator).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should set _targetBranches for QuestionClassifier nodes', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'qc-1',
|
||||
data: {
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
title: '',
|
||||
desc: '',
|
||||
classes: [{ id: 'cls-1', name: 'Class 1' }],
|
||||
model: { provider: 'openai' },
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect(result[0].data._targetBranches).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should set iteration node defaults', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'iter-1',
|
||||
data: {
|
||||
type: BlockEnum.Iteration,
|
||||
title: '',
|
||||
desc: '',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const iterNode = result.find(n => n.id === 'iter-1')!
|
||||
const data = iterNode.data as IterationNodeType
|
||||
expect(data.is_parallel).toBe(false)
|
||||
expect(data.parallel_nums).toBe(10)
|
||||
expect(data.error_handle_mode).toBe(ErrorHandleMode.Terminated)
|
||||
expect(data._children).toBeDefined()
|
||||
})
|
||||
|
||||
it('should set loop node defaults', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'loop-1',
|
||||
data: {
|
||||
type: BlockEnum.Loop,
|
||||
title: '',
|
||||
desc: '',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const loopNode = result.find(n => n.id === 'loop-1')!
|
||||
const data = loopNode.data as LoopNodeType
|
||||
expect(data.error_handle_mode).toBe(ErrorHandleMode.Terminated)
|
||||
expect(data._children).toBeDefined()
|
||||
})
|
||||
|
||||
it('should populate _children for iteration nodes with child nodes', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'iter-1',
|
||||
data: { type: BlockEnum.Iteration, title: '', desc: '' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'child-1',
|
||||
parentId: 'iter-1',
|
||||
data: { type: BlockEnum.Code, title: '', desc: '' },
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const iterNode = result.find(n => n.id === 'iter-1')!
|
||||
const data = iterNode.data as IterationNodeType
|
||||
expect(data._children).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ nodeId: 'child-1', nodeType: BlockEnum.Code }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should correct model provider for LLM nodes', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'llm-1',
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: '',
|
||||
desc: '',
|
||||
model: { provider: 'openai' },
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect((result[0].data as LLMNodeType).model.provider).toBe('corrected/openai')
|
||||
})
|
||||
|
||||
it('should correct model provider for KnowledgeRetrieval reranking_model', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'kr-1',
|
||||
data: {
|
||||
type: BlockEnum.KnowledgeRetrieval,
|
||||
title: '',
|
||||
desc: '',
|
||||
multiple_retrieval_config: {
|
||||
reranking_model: { provider: 'cohere' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect((result[0].data as KnowledgeRetrievalNodeType).multiple_retrieval_config!.reranking_model!.provider).toBe('corrected/cohere')
|
||||
})
|
||||
|
||||
it('should correct model provider for ParameterExtractor nodes', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'pe-1',
|
||||
data: {
|
||||
type: BlockEnum.ParameterExtractor,
|
||||
title: '',
|
||||
desc: '',
|
||||
model: { provider: 'anthropic' },
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect((result[0].data as ParameterExtractorNodeType).model.provider).toBe('corrected/anthropic')
|
||||
})
|
||||
|
||||
it('should add default retry_config for HttpRequest nodes', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'http-1',
|
||||
data: {
|
||||
type: BlockEnum.HttpRequest,
|
||||
title: '',
|
||||
desc: '',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect(result[0].data.retry_config).toEqual({
|
||||
retry_enabled: true,
|
||||
max_retries: DEFAULT_RETRY_MAX,
|
||||
retry_interval: DEFAULT_RETRY_INTERVAL,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not overwrite existing retry_config for HttpRequest nodes', () => {
|
||||
const existingConfig = { retry_enabled: false, max_retries: 1, retry_interval: 50 }
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'http-1',
|
||||
data: {
|
||||
type: BlockEnum.HttpRequest,
|
||||
title: '',
|
||||
desc: '',
|
||||
retry_config: existingConfig,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect(result[0].data.retry_config).toEqual(existingConfig)
|
||||
})
|
||||
|
||||
it('should migrate legacy Tool node configurations', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'tool-1',
|
||||
data: {
|
||||
type: BlockEnum.Tool,
|
||||
title: '',
|
||||
desc: '',
|
||||
tool_configurations: {
|
||||
api_key: 'secret-key',
|
||||
nested: { type: 'constant', value: 'already-migrated' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const data = result[0].data as ToolNodeType
|
||||
expect(data.tool_node_version).toBe('2')
|
||||
expect(data.tool_configurations.api_key).toEqual({
|
||||
type: 'constant',
|
||||
value: 'secret-key',
|
||||
})
|
||||
expect(data.tool_configurations.nested).toEqual({
|
||||
type: 'constant',
|
||||
value: 'already-migrated',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not migrate Tool node when version already exists', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'tool-1',
|
||||
data: {
|
||||
type: BlockEnum.Tool,
|
||||
title: '',
|
||||
desc: '',
|
||||
version: '1',
|
||||
tool_configurations: { key: 'val' },
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const data = result[0].data as ToolNodeType
|
||||
expect(data.tool_configurations).toEqual({ key: 'val' })
|
||||
})
|
||||
|
||||
it('should not migrate Tool node when tool_node_version already exists', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'tool-1',
|
||||
data: {
|
||||
type: BlockEnum.Tool,
|
||||
title: '',
|
||||
desc: '',
|
||||
tool_node_version: '2',
|
||||
tool_configurations: { key: 'val' },
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const data = result[0].data as ToolNodeType
|
||||
expect(data.tool_configurations).toEqual({ key: 'val' })
|
||||
})
|
||||
|
||||
it('should handle Tool node with null configuration value', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'tool-1',
|
||||
data: {
|
||||
type: BlockEnum.Tool,
|
||||
title: '',
|
||||
desc: '',
|
||||
tool_configurations: { key: null },
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const data = result[0].data as ToolNodeType
|
||||
expect(data.tool_configurations.key).toEqual({ type: 'constant', value: null })
|
||||
})
|
||||
|
||||
it('should handle Tool node with empty tool_configurations', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'tool-1',
|
||||
data: {
|
||||
type: BlockEnum.Tool,
|
||||
title: '',
|
||||
desc: '',
|
||||
tool_configurations: {},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
const data = result[0].data as ToolNodeType
|
||||
expect(data.tool_node_version).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialEdges', () => {
|
||||
it('should set edge type to custom', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [createEdge({ source: 'a', target: 'b' })]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
expect(result[0].type).toBe('custom')
|
||||
})
|
||||
|
||||
it('should set default sourceHandle and targetHandle', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edge = createEdge({ source: 'a', target: 'b' })
|
||||
Reflect.deleteProperty(edge, 'sourceHandle')
|
||||
Reflect.deleteProperty(edge, 'targetHandle')
|
||||
|
||||
const result = initialEdges([edge], nodes)
|
||||
expect(result[0].sourceHandle).toBe('source')
|
||||
expect(result[0].targetHandle).toBe('target')
|
||||
})
|
||||
|
||||
it('should set sourceType and targetType from nodes', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [createEdge({ source: 'a', target: 'b' })]
|
||||
Reflect.deleteProperty(edges[0].data!, 'sourceType')
|
||||
Reflect.deleteProperty(edges[0].data!, 'targetType')
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
expect(result[0].data!.sourceType).toBe(BlockEnum.Start)
|
||||
expect(result[0].data!.targetType).toBe(BlockEnum.Code)
|
||||
})
|
||||
|
||||
it('should set _connectedNodeIsSelected when a node is selected', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '', selected: true } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [createEdge({ source: 'a', target: 'b' })]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
expect(result[0].data!._connectedNodeIsSelected).toBe(true)
|
||||
})
|
||||
|
||||
it('should filter cycle edges', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
createNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'a', target: 'b' }),
|
||||
createEdge({ source: 'b', target: 'c' }),
|
||||
createEdge({ source: 'c', target: 'b' }),
|
||||
]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
const hasCycleEdge = result.some(
|
||||
e => (e.source === 'b' && e.target === 'c') || (e.source === 'c' && e.target === 'b'),
|
||||
)
|
||||
const hasABEdge = result.some(
|
||||
e => e.source === 'a' && e.target === 'b',
|
||||
)
|
||||
expect(hasCycleEdge).toBe(false)
|
||||
// In this specific graph, getCycleEdges treats all nodes remaining in the DFS stack (a, b, c)
|
||||
// as part of the cycle, so a→b is also filtered. This assertion documents that behaviour.
|
||||
expect(hasABEdge).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep non-cycle edges intact', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [createEdge({ source: 'a', target: 'b' })]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].source).toBe('a')
|
||||
expect(result[0].target).toBe('b')
|
||||
})
|
||||
|
||||
it('should handle empty edges', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
]
|
||||
const result = initialEdges([], nodes)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle edges where source/target node is missing from nodesMap', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [createEdge({ source: 'a', target: 'missing' })]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should set _connectedNodeIsSelected for edge target matching selected node', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '', selected: true } }),
|
||||
]
|
||||
const edges = [createEdge({ source: 'a', target: 'b' })]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
expect(result[0].data!._connectedNodeIsSelected).toBe(true)
|
||||
})
|
||||
|
||||
it('should not set default sourceHandle when sourceHandle already exists', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [createEdge({ source: 'a', target: 'b', sourceHandle: 'custom-src', targetHandle: 'custom-tgt' })]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
expect(result[0].sourceHandle).toBe('custom-src')
|
||||
expect(result[0].targetHandle).toBe('custom-tgt')
|
||||
})
|
||||
|
||||
it('should handle graph with edges referencing nodes not in the node list', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'a', target: 'b' }),
|
||||
createEdge({ source: 'unknown-src', target: 'unknown-tgt' }),
|
||||
]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
expect(result.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should handle self-referencing cycle', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'a', target: 'b' }),
|
||||
createEdge({ source: 'b', target: 'b' }),
|
||||
]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
const selfLoop = result.find(e => e.source === 'b' && e.target === 'b')
|
||||
expect(selfLoop).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle complex cycle with multiple nodes', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
createNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
createNode({ id: 'd', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'a', target: 'b' }),
|
||||
createEdge({ source: 'b', target: 'c' }),
|
||||
createEdge({ source: 'c', target: 'd' }),
|
||||
createEdge({ source: 'd', target: 'b' }),
|
||||
]
|
||||
|
||||
const result = initialEdges(edges, nodes)
|
||||
expect(result.length).toBeLessThan(edges.length)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
|
||||
import { BlockEnum } from '../../types'
|
||||
import {
|
||||
canRunBySingle,
|
||||
changeNodesAndEdgesId,
|
||||
getNodesConnectedSourceOrTargetHandleIdsMap,
|
||||
getValidTreeNodes,
|
||||
hasErrorHandleNode,
|
||||
isSupportCustomRunForm,
|
||||
} from '../workflow'
|
||||
|
||||
beforeEach(() => {
|
||||
resetFixtureCounters()
|
||||
})
|
||||
|
||||
describe('canRunBySingle', () => {
|
||||
const runnableTypes = [
|
||||
BlockEnum.LLM,
|
||||
BlockEnum.KnowledgeRetrieval,
|
||||
BlockEnum.Code,
|
||||
BlockEnum.TemplateTransform,
|
||||
BlockEnum.QuestionClassifier,
|
||||
BlockEnum.HttpRequest,
|
||||
BlockEnum.Tool,
|
||||
BlockEnum.ParameterExtractor,
|
||||
BlockEnum.Iteration,
|
||||
BlockEnum.Agent,
|
||||
BlockEnum.DocExtractor,
|
||||
BlockEnum.Loop,
|
||||
BlockEnum.Start,
|
||||
BlockEnum.IfElse,
|
||||
BlockEnum.VariableAggregator,
|
||||
BlockEnum.Assigner,
|
||||
BlockEnum.HumanInput,
|
||||
BlockEnum.DataSource,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
it.each(runnableTypes)('should return true for %s when not a child node', (type) => {
|
||||
expect(canRunBySingle(type, false)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for Assigner when it is a child node', () => {
|
||||
expect(canRunBySingle(BlockEnum.Assigner, true)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for LLM even as a child node', () => {
|
||||
expect(canRunBySingle(BlockEnum.LLM, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for End node', () => {
|
||||
expect(canRunBySingle(BlockEnum.End, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for Answer node', () => {
|
||||
expect(canRunBySingle(BlockEnum.Answer, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSupportCustomRunForm', () => {
|
||||
it('should return true for DataSource', () => {
|
||||
expect(isSupportCustomRunForm(BlockEnum.DataSource)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for other types', () => {
|
||||
expect(isSupportCustomRunForm(BlockEnum.LLM)).toBe(false)
|
||||
expect(isSupportCustomRunForm(BlockEnum.Code)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasErrorHandleNode', () => {
|
||||
it.each([BlockEnum.LLM, BlockEnum.Tool, BlockEnum.HttpRequest, BlockEnum.Code, BlockEnum.Agent])(
|
||||
'should return true for %s',
|
||||
(type) => {
|
||||
expect(hasErrorHandleNode(type)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it('should return false for non-error-handle types', () => {
|
||||
expect(hasErrorHandleNode(BlockEnum.Start)).toBe(false)
|
||||
expect(hasErrorHandleNode(BlockEnum.Iteration)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when undefined', () => {
|
||||
expect(hasErrorHandleNode()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodesConnectedSourceOrTargetHandleIdsMap', () => {
|
||||
it('should add handle ids when type is add', () => {
|
||||
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
|
||||
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
|
||||
const edge = createEdge({
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
sourceHandle: 'src-handle',
|
||||
targetHandle: 'tgt-handle',
|
||||
})
|
||||
|
||||
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[{ type: 'add', edge }],
|
||||
[node1, node2],
|
||||
)
|
||||
|
||||
expect(result.a._connectedSourceHandleIds).toContain('src-handle')
|
||||
expect(result.b._connectedTargetHandleIds).toContain('tgt-handle')
|
||||
})
|
||||
|
||||
it('should remove handle ids when type is remove', () => {
|
||||
const node1 = createNode({
|
||||
id: 'a',
|
||||
data: { type: BlockEnum.Start, title: '', desc: '', _connectedSourceHandleIds: ['src-handle'] },
|
||||
})
|
||||
const node2 = createNode({
|
||||
id: 'b',
|
||||
data: { type: BlockEnum.Code, title: '', desc: '', _connectedTargetHandleIds: ['tgt-handle'] },
|
||||
})
|
||||
const edge = createEdge({
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
sourceHandle: 'src-handle',
|
||||
targetHandle: 'tgt-handle',
|
||||
})
|
||||
|
||||
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[{ type: 'remove', edge }],
|
||||
[node1, node2],
|
||||
)
|
||||
|
||||
expect(result.a._connectedSourceHandleIds).not.toContain('src-handle')
|
||||
expect(result.b._connectedTargetHandleIds).not.toContain('tgt-handle')
|
||||
})
|
||||
|
||||
it('should use default handle ids when sourceHandle/targetHandle are missing', () => {
|
||||
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
|
||||
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
|
||||
const edge = createEdge({ source: 'a', target: 'b' })
|
||||
Reflect.deleteProperty(edge, 'sourceHandle')
|
||||
Reflect.deleteProperty(edge, 'targetHandle')
|
||||
|
||||
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[{ type: 'add', edge }],
|
||||
[node1, node2],
|
||||
)
|
||||
|
||||
expect(result.a._connectedSourceHandleIds).toContain('source')
|
||||
expect(result.b._connectedTargetHandleIds).toContain('target')
|
||||
})
|
||||
|
||||
it('should skip when source node is not found', () => {
|
||||
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
|
||||
const edge = createEdge({ source: 'missing', target: 'b', sourceHandle: 'src' })
|
||||
|
||||
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[{ type: 'add', edge }],
|
||||
[node2],
|
||||
)
|
||||
|
||||
expect(result.missing).toBeUndefined()
|
||||
expect(result.b._connectedTargetHandleIds).toBeDefined()
|
||||
})
|
||||
|
||||
it('should skip when target node is not found', () => {
|
||||
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
|
||||
const edge = createEdge({ source: 'a', target: 'missing', targetHandle: 'tgt' })
|
||||
|
||||
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[{ type: 'add', edge }],
|
||||
[node1],
|
||||
)
|
||||
|
||||
expect(result.a._connectedSourceHandleIds).toBeDefined()
|
||||
expect(result.missing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should reuse existing map entry for same node across multiple changes', () => {
|
||||
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
|
||||
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
|
||||
const node3 = createNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } })
|
||||
const edge1 = createEdge({ source: 'a', target: 'b', sourceHandle: 'h1' })
|
||||
const edge2 = createEdge({ source: 'a', target: 'c', sourceHandle: 'h2' })
|
||||
|
||||
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[{ type: 'add', edge: edge1 }, { type: 'add', edge: edge2 }],
|
||||
[node1, node2, node3],
|
||||
)
|
||||
|
||||
expect(result.a._connectedSourceHandleIds).toContain('h1')
|
||||
expect(result.a._connectedSourceHandleIds).toContain('h2')
|
||||
})
|
||||
|
||||
it('should fallback to empty arrays when node data has no handle id arrays', () => {
|
||||
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
|
||||
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
|
||||
Reflect.deleteProperty(node1.data, '_connectedSourceHandleIds')
|
||||
Reflect.deleteProperty(node1.data, '_connectedTargetHandleIds')
|
||||
Reflect.deleteProperty(node2.data, '_connectedSourceHandleIds')
|
||||
Reflect.deleteProperty(node2.data, '_connectedTargetHandleIds')
|
||||
|
||||
const edge = createEdge({ source: 'a', target: 'b', sourceHandle: 'h1', targetHandle: 'h2' })
|
||||
|
||||
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[{ type: 'add', edge }],
|
||||
[node1, node2],
|
||||
)
|
||||
|
||||
expect(result.a._connectedSourceHandleIds).toContain('h1')
|
||||
expect(result.b._connectedTargetHandleIds).toContain('h2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getValidTreeNodes', () => {
|
||||
it('should return empty when there are no start/trigger nodes', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const result = getValidTreeNodes(nodes, [])
|
||||
expect(result.validNodes).toEqual([])
|
||||
expect(result.maxDepth).toBe(0)
|
||||
})
|
||||
|
||||
it('should traverse a linear graph from Start', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: '', desc: '' } }),
|
||||
createNode({ id: 'end', data: { type: BlockEnum.End, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'llm' }),
|
||||
createEdge({ source: 'llm', target: 'end' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes.map(n => n.id)).toEqual(['start', 'llm', 'end'])
|
||||
expect(result.maxDepth).toBe(3)
|
||||
})
|
||||
|
||||
it('should traverse from trigger nodes', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'trigger', data: { type: BlockEnum.TriggerWebhook, title: '', desc: '' } }),
|
||||
createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'trigger', target: 'code' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes.map(n => n.id)).toContain('trigger')
|
||||
expect(result.validNodes.map(n => n.id)).toContain('code')
|
||||
})
|
||||
|
||||
it('should include iteration children as valid nodes', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'iter', data: { type: BlockEnum.Iteration, title: '', desc: '' } }),
|
||||
createNode({ id: 'child1', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'iter' }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'iter' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes.map(n => n.id)).toContain('child1')
|
||||
})
|
||||
|
||||
it('should include loop children when loop has outgoers', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'loop', data: { type: BlockEnum.Loop, title: '', desc: '' } }),
|
||||
createNode({ id: 'loop-child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'loop' }),
|
||||
createNode({ id: 'end', data: { type: BlockEnum.End, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'loop' }),
|
||||
createEdge({ source: 'loop', target: 'end' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes.map(n => n.id)).toContain('loop-child')
|
||||
})
|
||||
|
||||
it('should include loop children as valid nodes when loop is a leaf', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'loop', data: { type: BlockEnum.Loop, title: '', desc: '' } }),
|
||||
createNode({ id: 'loop-child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'loop' }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'loop' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes.map(n => n.id)).toContain('loop-child')
|
||||
})
|
||||
|
||||
it('should handle cycles without infinite loop', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'a' }),
|
||||
createEdge({ source: 'a', target: 'b' }),
|
||||
createEdge({ source: 'b', target: 'a' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should exclude disconnected nodes', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'connected', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
createNode({ id: 'isolated', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'connected' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes.map(n => n.id)).not.toContain('isolated')
|
||||
})
|
||||
|
||||
it('should handle multiple start nodes without double-traversal', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'start1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'trigger', data: { type: BlockEnum.TriggerSchedule, title: '', desc: '' } }),
|
||||
createNode({ id: 'shared', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'start1', target: 'shared' }),
|
||||
createEdge({ source: 'trigger', target: 'shared' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes.map(n => n.id)).toContain('start1')
|
||||
expect(result.validNodes.map(n => n.id)).toContain('trigger')
|
||||
expect(result.validNodes.map(n => n.id)).toContain('shared')
|
||||
})
|
||||
|
||||
it('should not increase maxDepth when visiting nodes at same or lower depth', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'a' }),
|
||||
createEdge({ source: 'start', target: 'b' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.maxDepth).toBe(2)
|
||||
})
|
||||
|
||||
it('should traverse from all trigger types', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'ts', data: { type: BlockEnum.TriggerSchedule, title: '', desc: '' } }),
|
||||
createNode({ id: 'tp', data: { type: BlockEnum.TriggerPlugin, title: '', desc: '' } }),
|
||||
createNode({ id: 'code1', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
createNode({ id: 'code2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'ts', target: 'code1' }),
|
||||
createEdge({ source: 'tp', target: 'code2' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('should skip start nodes already visited by a previous start node traversal', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'start1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'start2', data: { type: BlockEnum.TriggerWebhook, title: '', desc: '' } }),
|
||||
createNode({ id: 'shared', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'start1', target: 'start2' }),
|
||||
createEdge({ source: 'start2', target: 'shared' }),
|
||||
]
|
||||
|
||||
const result = getValidTreeNodes(nodes, edges)
|
||||
expect(result.validNodes.map(n => n.id)).toContain('start1')
|
||||
expect(result.validNodes.map(n => n.id)).toContain('start2')
|
||||
expect(result.validNodes.map(n => n.id)).toContain('shared')
|
||||
})
|
||||
})
|
||||
|
||||
describe('changeNodesAndEdgesId', () => {
|
||||
it('should replace all node and edge ids with new uuids', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'old-1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
createNode({ id: 'old-2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ source: 'old-1', target: 'old-2' }),
|
||||
]
|
||||
|
||||
const [newNodes, newEdges] = changeNodesAndEdgesId(nodes, edges)
|
||||
|
||||
expect(newNodes[0].id).not.toBe('old-1')
|
||||
expect(newNodes[1].id).not.toBe('old-2')
|
||||
expect(newEdges[0].source).toBe(newNodes[0].id)
|
||||
expect(newEdges[0].target).toBe(newNodes[1].id)
|
||||
})
|
||||
|
||||
it('should generate unique ids for all nodes', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'a' }),
|
||||
createNode({ id: 'b' }),
|
||||
createNode({ id: 'c' }),
|
||||
]
|
||||
|
||||
const [newNodes] = changeNodesAndEdgesId(nodes, [])
|
||||
const ids = new Set(newNodes.map(n => n.id))
|
||||
expect(ids.size).toBe(3)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import type {
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { preprocessNodesAndEdges } from './workflow-init'
|
||||
|
||||
describe('preprocessNodesAndEdges', () => {
|
||||
it('process nodes without iteration node or loop node should return origin nodes and edges.', () => {
|
||||
const nodes = [
|
||||
{
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
expect(result).toEqual({
|
||||
nodes,
|
||||
edges: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('process nodes with iteration node should return nodes with iteration start node', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'iteration',
|
||||
data: {
|
||||
type: BlockEnum.Iteration,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
expect(result.nodes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: BlockEnum.IterationStart,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('process nodes with iteration node start should return origin', () => {
|
||||
const nodes = [
|
||||
{
|
||||
data: {
|
||||
type: BlockEnum.Iteration,
|
||||
start_node_id: 'iterationStart',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'iterationStart',
|
||||
type: CUSTOM_ITERATION_START_NODE,
|
||||
data: {
|
||||
type: BlockEnum.IterationStart,
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
expect(result).toEqual({
|
||||
nodes,
|
||||
edges: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue