test(workflow): add tests for edge interactions, curl panel parsing, and iteration log behavior

- Implement tests for handling edge deletion by ID and source handle changes in useEdgesInteractions.
- Update curl-panel tests to use the curlParser module for parsing commands.
- Add tests to ensure iteration log correctly handles empty structured lists and counts failed iterations based on execution data.
This commit is contained in:
CodingOnStar 2026-03-24 15:12:06 +08:00
parent 98b2a36219
commit d8704d7124
5 changed files with 235 additions and 6 deletions

View File

@ -291,6 +291,17 @@ describe('useEdgesInteractions', () => {
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('handleEdgeDeleteById should ignore unknown edge ids', () => {
const { result } = renderEdgesInteractions()
act(() => {
result.current.handleEdgeDeleteById('missing-edge')
})
expect(result.current.edges).toHaveLength(2)
expect(mockSaveStateToHistory).not.toHaveBeenCalled()
})
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', async () => {
const { result, store } = renderEdgesInteractions({
initialStoreState: {
@ -335,6 +346,46 @@ describe('useEdgesInteractions', () => {
})
})
it('handleEdgeSourceHandleChange should clear edgeMenu and save history for affected edges', async () => {
const { result, store } = renderEdgesInteractions({
edges: [
createEdge({
id: 'n1-old-handle-n2-target',
source: 'n1',
target: 'n2',
sourceHandle: 'old-handle',
targetHandle: 'target',
data: {},
}),
],
initialStoreState: {
edgeMenu: { clientX: 120, clientY: 60, edgeId: 'n1-old-handle-n2-target' },
},
})
act(() => {
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
})
await waitFor(() => {
expect(result.current.edges[0]?.sourceHandle).toBe('new-handle')
})
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeSourceHandleChange')
})
it('handleEdgeSourceHandleChange should do nothing when no edges use the old handle', () => {
const { result } = renderEdgesInteractions()
act(() => {
result.current.handleEdgeSourceHandleChange('n1', 'missing-handle', 'new-handle')
})
expect(result.current.edges.map(edge => edge.id)).toEqual(['e1', 'e2'])
expect(mockSaveStateToHistory).not.toHaveBeenCalled()
})
describe('read-only mode', () => {
beforeEach(() => {
mockReadOnly = true
@ -412,5 +463,27 @@ describe('useEdgesInteractions', () => {
expect(result.current.edges).toHaveLength(2)
})
it('handleEdgeSourceHandleChange should do nothing', () => {
const { result } = renderEdgesInteractions({
edges: [
createEdge({
id: 'n1-old-handle-n2-target',
source: 'n1',
target: 'n2',
sourceHandle: 'old-handle',
targetHandle: 'target',
data: {},
}),
],
})
act(() => {
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
})
expect(result.current.edges[0]?.sourceHandle).toBe('old-handle')
expect(mockSaveStateToHistory).not.toHaveBeenCalled()
})
})
})

View File

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BodyPayloadValueType, BodyType } from '../../types'
import CurlPanel from '../curl-panel'
import { parseCurl } from '../curl-parser'
import * as curlParser from '../curl-parser'
const {
mockHandleNodeSelect,
@ -31,7 +31,7 @@ describe('curl-panel', () => {
describe('parseCurl', () => {
it('should parse method, headers, json body, and query params from a valid curl command', () => {
const { node, error } = parseCurl('curl -X POST -H \"Authorization: Bearer token\" --json \"{\"name\":\"openai\"}\" https://example.com/users?page=1&size=2')
const { node, error } = curlParser.parseCurl('curl -X POST -H \"Authorization: Bearer token\" --json \"{\"name\":\"openai\"}\" https://example.com/users?page=1&size=2')
expect(error).toBeNull()
expect(node).toMatchObject({
@ -43,11 +43,11 @@ describe('curl-panel', () => {
})
it('should return an error for invalid curl input', () => {
expect(parseCurl('fetch https://example.com').error).toContain('Invalid cURL command')
expect(curlParser.parseCurl('fetch https://example.com').error).toContain('Invalid cURL command')
})
it('should parse form data and attach typed content headers', () => {
const { node, error } = parseCurl('curl --request POST --form "file=@report.txt;type=text/plain" --form "name=openai" https://example.com/upload')
const { node, error } = curlParser.parseCurl('curl --request POST --form "file=@report.txt;type=text/plain" --form "name=openai" https://example.com/upload')
expect(error).toBeNull()
expect(node).toMatchObject({
@ -62,7 +62,7 @@ describe('curl-panel', () => {
})
it('should parse raw payloads and preserve equals signs in the body value', () => {
const { node, error } = parseCurl('curl --data-binary "token=abc=123" https://example.com/raw')
const { node, error } = curlParser.parseCurl('curl --data-binary "token=abc=123" https://example.com/raw')
expect(error).toBeNull()
expect(node?.body).toEqual({
@ -83,7 +83,7 @@ describe('curl-panel', () => {
['curl --form "=broken" https://example.com/upload', 'Invalid form data format.'],
['curl -H "Accept: application/json"', 'Missing URL or url not start with http.'],
])('should return a descriptive error for %s', (command, expectedError) => {
expect(parseCurl(command)).toEqual({
expect(curlParser.parseCurl(command)).toEqual({
node: null,
error: expectedError,
})
@ -135,5 +135,31 @@ describe('curl-panel', () => {
type: 'error',
}))
})
it('should keep the panel open when parsing returns no node and no error', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
const handleCurlImport = vi.fn()
vi.spyOn(curlParser, 'parseCurl').mockReturnValueOnce({
node: null,
error: null,
})
render(
<CurlPanel
nodeId="node-1"
isShow
onHide={onHide}
handleCurlImport={handleCurlImport}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onHide).not.toHaveBeenCalled()
expect(handleCurlImport).not.toHaveBeenCalled()
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
})
})

View File

@ -107,6 +107,43 @@ describe('useNodeIterationInteractions', () => {
})
})
it('should rerender the parent iteration node when a child size changes', () => {
mockGetNodes.mockReturnValue([
createIterationNode({
id: 'iteration-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'iteration-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
result.current.handleNodeIterationChildSizeChange('child-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
})
it('should skip iteration rerender when the resized node has no parent', () => {
mockGetNodes.mockReturnValue([
createNode({
id: 'standalone-node',
data: { type: BlockEnum.Code, title: 'Standalone', desc: '' },
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
result.current.handleNodeIterationChildSizeChange('standalone-node')
expect(mockSetNodes).not.toHaveBeenCalled()
})
it('should copy iteration children and remap ids', () => {
mockGetNodes.mockReturnValue([
createIterationNode({ id: 'iteration-node' }),

View File

@ -100,6 +100,43 @@ describe('useNodeLoopInteractions', () => {
})
})
it('should rerender the parent loop node when a child size changes', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopChildSizeChange('child-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
})
it('should skip loop rerender when the resized node has no parent', () => {
mockGetNodes.mockReturnValue([
createNode({
id: 'standalone-node',
data: { type: BlockEnum.Code, title: 'Standalone', desc: '' },
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopChildSizeChange('standalone-node')
expect(mockSetNodes).not.toHaveBeenCalled()
})
it('should copy loop children and remap ids', () => {
mockGetNodes.mockReturnValue([
createLoopNode({ id: 'loop-node' }),

View File

@ -129,5 +129,61 @@ describe('IterationLogTrigger', () => {
expect(onShowIterationResultList).toHaveBeenCalledWith(detailList, {})
})
it('should return an empty structured list when duration map exists without executions', async () => {
const user = userEvent.setup()
const onShowIterationResultList = vi.fn()
const iterationDurationMap: IterationDurationMap = { orphaned: 1.5 }
render(
<IterationLogTrigger
nodeInfo={createNodeTracing({
execution_metadata: createExecutionMetadata({
iteration_duration_map: iterationDurationMap,
}),
})}
onShowIterationResultList={onShowIterationResultList}
/>,
)
await user.click(screen.getByRole('button'))
expect(onShowIterationResultList).toHaveBeenCalledWith([], iterationDurationMap)
})
it('should count failed iterations from allExecutions and ignore unmatched duration map keys', async () => {
const user = userEvent.setup()
const onShowIterationResultList = vi.fn()
const iterationDurationMap: IterationDurationMap = { orphaned: 0.6, 1: 1.1 }
const allExecutions = [
createNodeTracing({
id: 'failed-serial-step',
status: NodeRunningStatus.Failed,
execution_metadata: createExecutionMetadata({
iteration_id: 'iteration-node',
iteration_index: 1,
}),
}),
]
render(
<IterationLogTrigger
nodeInfo={createNodeTracing({
details: [[createNodeTracing({ id: 'detail-success' })]],
execution_metadata: createExecutionMetadata({
iteration_duration_map: iterationDurationMap,
}),
})}
allExecutions={allExecutions}
onShowIterationResultList={onShowIterationResultList}
/>,
)
expect(screen.getByRole('button', { name: /workflow\.nodes\.iteration\.error/i })).toBeInTheDocument()
await user.click(screen.getByRole('button'))
expect(onShowIterationResultList).toHaveBeenCalledWith([[allExecutions[0]]], iterationDurationMap)
})
})
})