Merge main HEAD (segment 5) into sandboxed-agent-rebase

Resolve 83 conflicts: 10 backend, 62 frontend, 11 config/lock files.
Preserve sandbox/agent/collaboration features while adopting main's
UI refactorings (Dialog/AlertDialog/Popover), model provider updates,
and enterprise features.

Made-with: Cursor
This commit is contained in:
Novice
2026-03-23 14:20:06 +08:00
1671 changed files with 124822 additions and 22302 deletions

View File

@ -0,0 +1,40 @@
import type { Node } from '../types'
import { screen } from '@testing-library/react'
import CandidateNode from '../candidate-node'
import { BlockEnum } from '../types'
import { renderWorkflowComponent } from './workflow-test-env'
vi.mock('../candidate-node-main', () => ({
default: ({ candidateNode }: { candidateNode: Node }) => (
<div data-testid="candidate-node-main">{candidateNode.id}</div>
),
}))
const createCandidateNode = (): Node => ({
id: 'candidate-node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'Candidate node',
desc: 'candidate',
},
})
describe('CandidateNode', () => {
it('should not render when candidateNode is missing from the workflow store', () => {
renderWorkflowComponent(<CandidateNode />)
expect(screen.queryByTestId('candidate-node-main')).not.toBeInTheDocument()
})
it('should render CandidateNodeMain with the stored candidate node', () => {
renderWorkflowComponent(<CandidateNode />, {
initialStoreState: {
candidateNode: createCandidateNode(),
},
})
expect(screen.getByTestId('candidate-node-main')).toHaveTextContent('candidate-node-1')
})
})

View File

@ -0,0 +1,81 @@
import type { ComponentProps } from 'react'
import { render } from '@testing-library/react'
import { getBezierPath, Position } from 'reactflow'
import CustomConnectionLine from '../custom-connection-line'
const createConnectionLineProps = (
overrides: Partial<ComponentProps<typeof CustomConnectionLine>> = {},
): ComponentProps<typeof CustomConnectionLine> => ({
fromX: 10,
fromY: 20,
toX: 70,
toY: 80,
fromPosition: Position.Right,
toPosition: Position.Left,
connectionLineType: undefined,
connectionStatus: null,
...overrides,
} as ComponentProps<typeof CustomConnectionLine>)
describe('CustomConnectionLine', () => {
it('should render the bezier path and target marker', () => {
const [expectedPath] = getBezierPath({
sourceX: 10,
sourceY: 20,
sourcePosition: Position.Right,
targetX: 70,
targetY: 80,
targetPosition: Position.Left,
curvature: 0.16,
})
const { container } = render(
<svg>
<CustomConnectionLine {...createConnectionLineProps()} />
</svg>,
)
const path = container.querySelector('path')
const marker = container.querySelector('rect')
expect(path).toHaveAttribute('fill', 'none')
expect(path).toHaveAttribute('stroke', '#D0D5DD')
expect(path).toHaveAttribute('stroke-width', '2')
expect(path).toHaveAttribute('d', expectedPath)
expect(marker).toHaveAttribute('x', '70')
expect(marker).toHaveAttribute('y', '76')
expect(marker).toHaveAttribute('width', '2')
expect(marker).toHaveAttribute('height', '8')
expect(marker).toHaveAttribute('fill', '#2970FF')
})
it('should update the path when the endpoints change', () => {
const [expectedPath] = getBezierPath({
sourceX: 30,
sourceY: 40,
sourcePosition: Position.Right,
targetX: 160,
targetY: 200,
targetPosition: Position.Left,
curvature: 0.16,
})
const { container } = render(
<svg>
<CustomConnectionLine
{...createConnectionLineProps({
fromX: 30,
fromY: 40,
toX: 160,
toY: 200,
})}
/>
</svg>,
)
expect(container.querySelector('path')).toHaveAttribute('d', expectedPath)
expect(container.querySelector('rect')).toHaveAttribute('x', '160')
expect(container.querySelector('rect')).toHaveAttribute('y', '196')
})
})

View File

@ -0,0 +1,57 @@
import { render } from '@testing-library/react'
import CustomEdgeLinearGradientRender from '../custom-edge-linear-gradient-render'
describe('CustomEdgeLinearGradientRender', () => {
it('should render gradient definition with the provided id and positions', () => {
const { container } = render(
<svg>
<CustomEdgeLinearGradientRender
id="edge-gradient"
startColor="#123456"
stopColor="#abcdef"
position={{
x1: 10,
y1: 20,
x2: 30,
y2: 40,
}}
/>
</svg>,
)
const gradient = container.querySelector('linearGradient')
expect(gradient).toHaveAttribute('id', 'edge-gradient')
expect(gradient).toHaveAttribute('gradientUnits', 'userSpaceOnUse')
expect(gradient).toHaveAttribute('x1', '10')
expect(gradient).toHaveAttribute('y1', '20')
expect(gradient).toHaveAttribute('x2', '30')
expect(gradient).toHaveAttribute('y2', '40')
})
it('should render start and stop colors at both ends of the gradient', () => {
const { container } = render(
<svg>
<CustomEdgeLinearGradientRender
id="gradient-colors"
startColor="#111111"
stopColor="#222222"
position={{
x1: 0,
y1: 0,
x2: 100,
y2: 100,
}}
/>
</svg>,
)
const stops = container.querySelectorAll('stop')
expect(stops).toHaveLength(2)
expect(stops[0]).toHaveAttribute('offset', '0%')
expect(stops[0].getAttribute('style')).toContain('stop-color: rgb(17, 17, 17)')
expect(stops[0].getAttribute('style')).toContain('stop-opacity: 1')
expect(stops[1]).toHaveAttribute('offset', '100%')
expect(stops[1].getAttribute('style')).toContain('stop-color: rgb(34, 34, 34)')
expect(stops[1].getAttribute('style')).toContain('stop-opacity: 1')
})
})

View File

@ -0,0 +1,127 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DSLExportConfirmModal from '../dsl-export-confirm-modal'
const envList = [
{
id: 'env-1',
name: 'SECRET_TOKEN',
value: 'masked-value',
value_type: 'secret' as const,
description: 'secret token',
},
]
const multiEnvList = [
...envList,
{
id: 'env-2',
name: 'SERVICE_KEY',
value: 'another-secret',
value_type: 'secret' as const,
description: 'service key',
},
]
describe('DSLExportConfirmModal', () => {
it('should render environment rows and close when cancel is clicked', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const onClose = vi.fn()
render(
<DSLExportConfirmModal
envList={envList}
onConfirm={onConfirm}
onClose={onClose}
/>,
)
expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument()
expect(screen.getByText('masked-value')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onClose).toHaveBeenCalledTimes(1)
expect(onConfirm).not.toHaveBeenCalled()
})
it('should confirm with exportSecrets=false by default', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const onClose = vi.fn()
render(
<DSLExportConfirmModal
envList={envList}
onConfirm={onConfirm}
onClose={onClose}
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.env.export.ignore' }))
expect(onConfirm).toHaveBeenCalledWith(false)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should confirm with exportSecrets=true after toggling the checkbox', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const onClose = vi.fn()
render(
<DSLExportConfirmModal
envList={envList}
onConfirm={onConfirm}
onClose={onClose}
/>,
)
await user.click(screen.getByRole('checkbox'))
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
expect(onConfirm).toHaveBeenCalledWith(true)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should also toggle exportSecrets when the label text is clicked', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const onClose = vi.fn()
render(
<DSLExportConfirmModal
envList={envList}
onConfirm={onConfirm}
onClose={onClose}
/>,
)
await user.click(screen.getByText('workflow.env.export.checkbox'))
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
expect(onConfirm).toHaveBeenCalledWith(true)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should render border separators for all rows except the last one', () => {
render(
<DSLExportConfirmModal
envList={multiEnvList}
onConfirm={vi.fn()}
onClose={vi.fn()}
/>,
)
const firstNameCell = screen.getByText('SECRET_TOKEN').closest('td')
const lastNameCell = screen.getByText('SERVICE_KEY').closest('td')
const firstValueCell = screen.getByText('masked-value').closest('td')
const lastValueCell = screen.getByText('another-secret').closest('td')
expect(firstNameCell).toHaveClass('border-b')
expect(firstValueCell).toHaveClass('border-b')
expect(lastNameCell).not.toHaveClass('border-b')
expect(lastValueCell).not.toHaveClass('border-b')
})
})

View File

@ -0,0 +1,410 @@
import type { Edge, Node } from '../types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { useEdges, useNodes, useStoreApi } from 'reactflow'
import { createEdge, createNode } from '../__tests__/fixtures'
import { renderWorkflowFlowComponent } from '../__tests__/workflow-test-env'
import EdgeContextmenu from '../edge-contextmenu'
import { useEdgesInteractions } from '../hooks/use-edges-interactions'
const mockSaveStateToHistory = vi.fn()
vi.mock('../hooks/use-workflow-history', () => ({
useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }),
WorkflowHistoryEvent: {
EdgeDelete: 'EdgeDelete',
EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
EdgeSourceHandleChange: 'EdgeSourceHandleChange',
},
}))
vi.mock('../hooks/use-workflow', () => ({
useNodesReadOnly: () => ({
getNodesReadOnly: () => false,
}),
}))
vi.mock('../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils')>()
return {
...actual,
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
}
})
vi.mock('../hooks', async () => {
const { useEdgesInteractions } = await import('../hooks/use-edges-interactions')
const { usePanelInteractions } = await import('../hooks/use-panel-interactions')
return {
useEdgesInteractions,
usePanelInteractions,
}
})
type EdgeRuntimeState = {
_hovering?: boolean
_isBundled?: boolean
}
type NodeRuntimeState = {
selected?: boolean
_isBundled?: boolean
}
const getEdgeRuntimeState = (edge?: Edge): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
const getNodeRuntimeState = (node?: Node): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
function createFlowNodes() {
return [
createNode({ id: 'n1' }),
createNode({ id: 'n2', position: { x: 100, y: 0 } }),
]
}
function createFlowEdges() {
return [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
data: { _hovering: false },
selected: true,
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
]
}
let latestNodes: Node[] = []
let latestEdges: Edge[] = []
const RuntimeProbe = () => {
latestNodes = useNodes() as Node[]
latestEdges = useEdges() as Edge[]
return null
}
const hooksStoreProps = {
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
}
const EdgeMenuHarness = () => {
const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions()
const edges = useEdges() as Edge[]
const reactFlowStore = useStoreApi()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Delete' && e.key !== 'Backspace')
return
e.preventDefault()
handleEdgeDelete()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [handleEdgeDelete])
return (
<div>
<RuntimeProbe />
<button
type="button"
aria-label="Right-click edge e1"
onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e1') as never)}
>
edge-e1
</button>
<button
type="button"
aria-label="Right-click edge e2"
onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e2') as never)}
>
edge-e2
</button>
<button
type="button"
aria-label="Remove edge e1"
onClick={() => {
const { edges, setEdges } = reactFlowStore.getState()
setEdges(edges.filter(edge => edge.id !== 'e1'))
}}
>
remove-e1
</button>
<EdgeContextmenu />
</div>
)
}
function renderEdgeMenu(options?: {
nodes?: Node[]
edges?: Edge[]
initialStoreState?: Record<string, unknown>
}) {
const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {}
return renderWorkflowFlowComponent(<EdgeMenuHarness />, {
nodes,
edges,
initialStoreState,
hooksStoreProps,
reactFlowProps: { fitView: false },
})
}
describe('EdgeContextmenu', () => {
beforeEach(() => {
vi.clearAllMocks()
latestNodes = []
latestEdges = []
})
it('should not render when edgeMenu is absent', () => {
renderWorkflowFlowComponent(<EdgeContextmenu />, {
nodes: createFlowNodes(),
edges: createFlowEdges(),
hooksStoreProps,
reactFlowProps: { fitView: false },
})
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('should delete the menu edge and close the menu when another edge is selected', async () => {
const user = userEvent.setup()
const { store } = renderEdgeMenu({
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
selected: true,
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
selected: false,
data: { _hovering: false },
}),
],
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'e2',
},
},
})
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
expect(screen.getByText(/^del$/i)).toBeInTheDocument()
await user.click(deleteAction)
await waitFor(() => {
expect(latestEdges).toHaveLength(1)
expect(latestEdges[0].id).toBe('e1')
expect(latestEdges[0].selected).toBe(true)
expect(store.getState().edgeMenu).toBeUndefined()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('should not render the menu when the referenced edge no longer exists', () => {
renderWorkflowFlowComponent(<EdgeContextmenu />, {
nodes: createFlowNodes(),
edges: createFlowEdges(),
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'missing-edge',
},
},
hooksStoreProps,
reactFlowProps: { fitView: false },
})
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('should open the edge menu at the right-click position', async () => {
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
renderEdgeMenu()
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
clientX: 320,
clientY: 180,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument()
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
x: 320,
y: 180,
width: 0,
height: 0,
}))
})
it('should delete the right-clicked edge and close the menu when delete is clicked', async () => {
const user = userEvent.setup()
renderEdgeMenu()
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
clientX: 320,
clientY: 180,
})
await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i }))
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(latestEdges.map(edge => edge.id)).toEqual(['e1'])
})
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it.each([
['Delete', 'Delete'],
['Backspace', 'Backspace'],
])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => {
renderEdgeMenu({
nodes: [
createNode({
id: 'n1',
selected: true,
data: { selected: true, _isBundled: true },
}),
createNode({
id: 'n2',
position: { x: 100, y: 0 },
}),
],
})
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
clientX: 240,
clientY: 120,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.keyDown(document.body, { key })
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(latestEdges.map(edge => edge.id)).toEqual(['e1'])
expect(latestNodes.map(node => node.id)).toEqual(['n1', 'n2'])
expect(latestNodes.every(node => !node.selected && !getNodeRuntimeState(node).selected)).toBe(true)
})
})
it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => {
renderEdgeMenu({
nodes: [
createNode({
id: 'n1',
selected: true,
data: { selected: true, _isBundled: true },
}),
createNode({
id: 'n2',
position: { x: 100, y: 0 },
selected: true,
data: { selected: true, _isBundled: true },
}),
],
})
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
clientX: 200,
clientY: 100,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.keyDown(document.body, { key: 'Delete' })
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(latestEdges.map(edge => edge.id)).toEqual(['e2'])
expect(latestNodes).toHaveLength(2)
expect(latestNodes.every(node =>
!node.selected
&& !getNodeRuntimeState(node).selected
&& !getNodeRuntimeState(node)._isBundled,
)).toBe(true)
})
})
it('should retarget the menu and selected edge when right-clicking a different edge', async () => {
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
renderEdgeMenu()
const edgeOneButton = screen.getByLabelText('Right-click edge e1')
const edgeTwoButton = screen.getByLabelText('Right-click edge e2')
fireEvent.contextMenu(edgeOneButton, {
clientX: 80,
clientY: 60,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.contextMenu(edgeTwoButton, {
clientX: 360,
clientY: 240,
})
await waitFor(() => {
expect(screen.getAllByRole('menu')).toHaveLength(1)
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
x: 360,
y: 240,
}))
expect(latestEdges.find(edge => edge.id === 'e1')?.selected).toBe(false)
expect(latestEdges.find(edge => edge.id === 'e2')?.selected).toBe(true)
expect(latestEdges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true)
})
})
it('should hide the menu when the target edge disappears after opening it', async () => {
const { container } = renderEdgeMenu()
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
clientX: 160,
clientY: 100,
})
expect(await screen.findByRole('menu')).toBeInTheDocument()
fireEvent.click(container.querySelector('button[aria-label="Remove edge e1"]') as HTMLButtonElement)
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,191 @@
import type { InputVar } from '../types'
import type { PromptVariable } from '@/models/debug'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useNodes } from 'reactflow'
import Features from '../features'
import { InputVarType } from '../types'
import { createStartNode } from './fixtures'
import { renderWorkflowFlowComponent } from './workflow-test-env'
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleAddVariable = vi.fn()
let mockIsChatMode = true
let mockNodesReadOnly = false
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useIsChatMode: () => mockIsChatMode,
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
}
})
vi.mock('../nodes/start/use-config', () => ({
default: () => ({
handleAddVariable: mockHandleAddVariable,
}),
}))
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
default: ({
show,
isChatMode,
disabled,
onChange,
onClose,
onAutoAddPromptVariable,
workflowVariables,
}: {
show: boolean
isChatMode: boolean
disabled: boolean
onChange: () => void
onClose: () => void
onAutoAddPromptVariable: (variables: PromptVariable[]) => void
workflowVariables: InputVar[]
}) => {
if (!show)
return null
return (
<section aria-label="new feature panel">
<div>{isChatMode ? 'chat mode' : 'completion mode'}</div>
<div>{disabled ? 'panel disabled' : 'panel enabled'}</div>
<ul aria-label="workflow variables">
{workflowVariables.map(variable => (
<li key={variable.variable}>
{`${variable.label}:${variable.variable}`}
</li>
))}
</ul>
<button type="button" onClick={onChange}>open features</button>
<button type="button" onClick={onClose}>close features</button>
<button
type="button"
onClick={() => onAutoAddPromptVariable([{
key: 'opening_statement',
name: 'Opening Statement',
type: 'string',
max_length: 200,
required: true,
}])}
>
add required variable
</button>
<button
type="button"
onClick={() => onAutoAddPromptVariable([{
key: 'optional_statement',
name: 'Optional Statement',
type: 'string',
max_length: 120,
}])}
>
add optional variable
</button>
</section>
)
},
}))
const startNode = createStartNode({
id: 'start-node',
data: {
variables: [{ variable: 'existing_variable', label: 'Existing Variable' }],
},
})
const DelayedFeatures = () => {
const nodes = useNodes()
if (!nodes.length)
return null
return <Features />
}
const renderFeatures = (options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>) =>
renderWorkflowFlowComponent(
<DelayedFeatures />,
{
nodes: [startNode],
edges: [],
...options,
},
)
describe('Features', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = true
mockNodesReadOnly = false
})
describe('Rendering', () => {
it('should pass workflow context to the feature panel', () => {
renderFeatures()
expect(screen.getByText('chat mode')).toBeInTheDocument()
expect(screen.getByText('panel enabled')).toBeInTheDocument()
expect(screen.getByRole('list', { name: 'workflow variables' })).toHaveTextContent('Existing Variable:existing_variable')
})
})
describe('User Interactions', () => {
it('should sync the draft and open the workflow feature panel when users change features', async () => {
const user = userEvent.setup()
const { store } = renderFeatures()
await user.click(screen.getByRole('button', { name: 'open features' }))
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(store.getState().showFeaturesPanel).toBe(true)
})
it('should close the workflow feature panel and transform required prompt variables', async () => {
const user = userEvent.setup()
const { store } = renderFeatures({
initialStoreState: {
showFeaturesPanel: true,
},
})
await user.click(screen.getByRole('button', { name: 'close features' }))
expect(store.getState().showFeaturesPanel).toBe(false)
await user.click(screen.getByRole('button', { name: 'add required variable' }))
expect(mockHandleAddVariable).toHaveBeenCalledWith({
variable: 'opening_statement',
label: 'Opening Statement',
type: InputVarType.textInput,
max_length: 200,
required: true,
options: [],
})
})
it('should default prompt variables to optional when required is omitted', async () => {
const user = userEvent.setup()
renderFeatures()
await user.click(screen.getByRole('button', { name: 'add optional variable' }))
expect(mockHandleAddVariable).toHaveBeenCalledWith({
variable: 'optional_statement',
label: 'Optional Statement',
type: InputVarType.textInput,
max_length: 120,
required: false,
options: [],
})
})
})
})

View File

@ -42,6 +42,13 @@ export function createStartNode(overrides: Omit<Partial<Node>, 'data'> & { data?
})
}
export function createNodeDataFactory<T extends CommonNodeType & Record<string, unknown>>(defaults: T) {
return (overrides: Partial<T> = {}): T => ({
...defaults,
...overrides,
})
}
export function createTriggerNode(
triggerType: BlockEnum.TriggerSchedule | BlockEnum.TriggerWebhook | BlockEnum.TriggerPlugin = BlockEnum.TriggerWebhook,
overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {},

View File

@ -0,0 +1,9 @@
import { vi } from 'vitest'
export function resolveDocLink(path: string, baseUrl = 'https://docs.example.com') {
return `${baseUrl}${path}`
}
export function createDocLinkMock(baseUrl = 'https://docs.example.com') {
return vi.fn((path: string) => resolveDocLink(path, baseUrl))
}

View File

@ -0,0 +1,179 @@
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
createCredentialState,
createDefaultModel,
createModel,
createModelItem,
createProviderMeta,
} from './model-provider-fixtures'
describe('model-provider-fixtures', () => {
describe('createModelItem', () => {
it('should return the default text embedding model item', () => {
expect(createModelItem()).toEqual({
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
})
})
it('should allow overriding the default model item fields', () => {
expect(createModelItem({
model: 'bge-large',
status: ModelStatusEnum.disabled,
load_balancing_enabled: true,
})).toEqual(expect.objectContaining({
model: 'bge-large',
status: ModelStatusEnum.disabled,
load_balancing_enabled: true,
}))
})
})
describe('createModel', () => {
it('should build an active provider model with one default model item', () => {
const result = createModel()
expect(result.provider).toBe('openai')
expect(result.status).toBe(ModelStatusEnum.active)
expect(result.models).toHaveLength(1)
expect(result.models[0]).toEqual(createModelItem())
})
it('should use override values for provider metadata and model list', () => {
const customModelItem = createModelItem({
model: 'rerank-v1',
model_type: ModelTypeEnum.rerank,
})
expect(createModel({
provider: 'cohere',
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
models: [customModelItem],
})).toEqual(expect.objectContaining({
provider: 'cohere',
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
models: [customModelItem],
}))
})
})
describe('createDefaultModel', () => {
it('should return the default provider and model selection', () => {
expect(createDefaultModel()).toEqual({
provider: 'openai',
model: 'text-embedding-3-large',
})
})
it('should allow overriding the default provider selection', () => {
expect(createDefaultModel({
provider: 'azure_openai',
model: 'text-embedding-3-small',
})).toEqual({
provider: 'azure_openai',
model: 'text-embedding-3-small',
})
})
})
describe('createProviderMeta', () => {
it('should return provider metadata with credential and system configuration defaults', () => {
expect(createProviderMeta()).toEqual({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: 'Help' },
url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' },
},
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
supported_model_types: [ModelTypeEnum.textEmbedding],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: {
credential_form_schemas: [],
},
model_credential_schema: {
model: {
label: { en_US: 'Model', zh_Hans: 'Model' },
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [],
},
})
})
it('should apply provider metadata overrides', () => {
expect(createProviderMeta({
provider: 'bedrock',
supported_model_types: [ModelTypeEnum.textGeneration],
preferred_provider_type: PreferredProviderTypeEnum.system,
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [],
},
})).toEqual(expect.objectContaining({
provider: 'bedrock',
supported_model_types: [ModelTypeEnum.textGeneration],
preferred_provider_type: PreferredProviderTypeEnum.system,
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [],
},
}))
})
})
describe('createCredentialState', () => {
it('should return the default active credential panel state', () => {
expect(createCredentialState()).toEqual({
variant: 'api-active',
priority: 'apiKeyOnly',
supportsCredits: false,
showPrioritySwitcher: false,
isCreditsExhausted: false,
hasCredentials: true,
credentialName: undefined,
credits: 0,
})
})
it('should allow overriding the credential panel state', () => {
expect(createCredentialState({
variant: 'credits-active',
supportsCredits: true,
showPrioritySwitcher: true,
credits: 12,
credentialName: 'Primary Key',
})).toEqual(expect.objectContaining({
variant: 'credits-active',
supportsCredits: true,
showPrioritySwitcher: true,
credits: 12,
credentialName: 'Primary Key',
}))
})
})
})

View File

@ -0,0 +1,97 @@
import type {
DefaultModel,
Model,
ModelItem,
ModelProvider,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
export function createModelItem(overrides: Partial<ModelItem> = {}): ModelItem {
return {
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
}
}
export function createModel(overrides: Partial<Model> = {}): Model {
return {
provider: 'openai',
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [createModelItem()],
status: ModelStatusEnum.active,
...overrides,
}
}
export function createDefaultModel(overrides: Partial<DefaultModel> = {}): DefaultModel {
return {
provider: 'openai',
model: 'text-embedding-3-large',
...overrides,
}
}
export function createProviderMeta(overrides: Partial<ModelProvider> = {}): ModelProvider {
return {
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: 'Help' },
url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' },
},
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
supported_model_types: [ModelTypeEnum.textEmbedding],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: {
credential_form_schemas: [],
},
model_credential_schema: {
model: {
label: { en_US: 'Model', zh_Hans: 'Model' },
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [],
},
...overrides,
}
}
export function createCredentialState(overrides: Partial<CredentialPanelState> = {}): CredentialPanelState {
return {
variant: 'api-active',
priority: 'apiKeyOnly',
supportsCredits: false,
showPrioritySwitcher: false,
isCreditsExhausted: false,
hasCredentials: true,
credentialName: undefined,
credits: 0,
...overrides,
}
}

View File

@ -16,8 +16,8 @@ import * as React from 'react'
type MockNode = {
id: string
position: { x: number, y: number }
width?: number
height?: number
width?: number | null
height?: number | null
parentId?: string
data: Record<string, unknown>
}

View File

@ -0,0 +1,22 @@
import SyncingDataModal from '../syncing-data-modal'
import { renderWorkflowComponent } from './workflow-test-env'
describe('SyncingDataModal', () => {
it('should not render when workflow draft syncing is disabled', () => {
const { container } = renderWorkflowComponent(<SyncingDataModal />)
expect(container).toBeEmptyDOMElement()
})
it('should render the fullscreen overlay when workflow draft syncing is enabled', () => {
const { container } = renderWorkflowComponent(<SyncingDataModal />, {
initialStoreState: {
isSyncingWorkflowDraft: true,
},
})
const overlay = container.firstElementChild
expect(overlay).toHaveClass('absolute', 'inset-0')
expect(overlay).toHaveClass('z-[9999]')
})
})

View File

@ -1,16 +1,12 @@
import type { EdgeChange, ReactFlowProps } from 'reactflow'
import type { Edge, Node } from '../types'
import { act, fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { BaseEdge, internalsSymbol, Position, ReactFlowProvider, useStoreApi } from 'reactflow'
import { FlowType } from '@/types/common'
import { WORKFLOW_DATA_UPDATE } from '../constants'
import { Workflow } from '../index'
import { renderWorkflowComponent } from './workflow-test-env'
const reactFlowState = vi.hoisted(() => ({
lastProps: null as ReactFlowProps | null,
}))
type WorkflowUpdateEvent = {
type: string
payload: {
@ -23,6 +19,10 @@ const eventEmitterState = vi.hoisted(() => ({
subscription: null as null | ((payload: WorkflowUpdateEvent) => void),
}))
const reactFlowBridge = vi.hoisted(() => ({
store: null as null | ReturnType<typeof useStoreApi>,
}))
const workflowHookMocks = vi.hoisted(() => ({
handleNodeDragStart: vi.fn(),
handleNodeDrag: vi.fn(),
@ -52,97 +52,64 @@ const workflowHookMocks = vi.hoisted(() => ({
useWorkflowSearch: vi.fn(),
}))
function createInitializedNode(id: string, x: number, label: string) {
return {
id,
position: { x, y: 0 },
positionAbsolute: { x, y: 0 },
width: 160,
height: 40,
sourcePosition: Position.Right,
targetPosition: Position.Left,
data: { label },
[internalsSymbol]: {
positionAbsolute: { x, y: 0 },
handleBounds: {
source: [{
id: null,
nodeId: id,
type: 'source',
position: Position.Right,
x: 160,
y: 0,
width: 0,
height: 40,
}],
target: [{
id: null,
nodeId: id,
type: 'target',
position: Position.Left,
x: 0,
y: 0,
width: 0,
height: 40,
}],
},
z: 0,
},
}
}
const baseNodes = [
{
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {},
},
createInitializedNode('node-1', 0, 'Workflow node node-1'),
createInitializedNode('node-2', 240, 'Workflow node node-2'),
] as unknown as Node[]
const baseEdges = [
{
id: 'edge-1',
type: 'custom',
source: 'node-1',
target: 'node-2',
data: { sourceType: 'start', targetType: 'end' },
},
] as unknown as Edge[]
const edgeChanges: EdgeChange[] = [{ id: 'edge-1', type: 'remove' }]
function createMouseEvent() {
return {
preventDefault: vi.fn(),
clientX: 24,
clientY: 48,
} as unknown as React.MouseEvent<Element, MouseEvent>
}
vi.mock('next/dynamic', () => ({
vi.mock('@/next/dynamic', () => ({
default: () => () => null,
}))
vi.mock('next/navigation', () => ({
useParams: () => ({ appId: 'test-app-id' }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/app/test-app-id',
}))
vi.mock('reactflow', async () => {
const mod = await import('./reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
const ReactFlowMock = (props: ReactFlowProps) => {
reactFlowState.lastProps = props
return React.createElement(
'div',
{ 'data-testid': 'reactflow-mock' },
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit edge mouse enter',
'onClick': () => props.onEdgeMouseEnter?.(createMouseEvent(), baseEdges[0]),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit edge mouse leave',
'onClick': () => props.onEdgeMouseLeave?.(createMouseEvent(), baseEdges[0]),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit edges change',
'onClick': () => props.onEdgesChange?.(edgeChanges),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit edge context menu',
'onClick': () => props.onEdgeContextMenu?.(createMouseEvent(), baseEdges[0]),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit node context menu',
'onClick': () => props.onNodeContextMenu?.(createMouseEvent(), baseNodes[0]),
}),
React.createElement('button', {
'type': 'button',
'aria-label': 'Emit pane context menu',
'onClick': () => props.onPaneContextMenu?.(createMouseEvent()),
}),
props.children,
)
}
return {
...base,
SelectionMode: {
Partial: 'partial',
},
ReactFlow: ReactFlowMock,
default: ReactFlowMock,
}
})
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
@ -173,7 +140,10 @@ vi.mock('../custom-connection-line', () => ({
}))
vi.mock('../custom-edge', () => ({
default: () => null,
default: () => React.createElement(BaseEdge, {
id: 'edge-1',
path: 'M 0 0 L 100 0',
}),
}))
vi.mock('../help-line', () => ({
@ -189,7 +159,7 @@ vi.mock('../node-contextmenu', () => ({
}))
vi.mock('../nodes', () => ({
default: () => null,
default: ({ id }: { id: string }) => React.createElement('div', { 'data-testid': `workflow-node-${id}` }, `Workflow node ${id}`),
}))
vi.mock('../nodes/data-source-empty', () => ({
@ -297,17 +267,24 @@ vi.mock('../nodes/_base/components/variable/use-match-schema-type', () => ({
}),
}))
vi.mock('../workflow-history-store', () => ({
WorkflowHistoryProvider: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
}))
function renderSubject(options?: {
nodes?: Node[]
edges?: Edge[]
initialStoreState?: Record<string, unknown>
}) {
const { nodes = baseNodes, edges = baseEdges, initialStoreState } = options ?? {}
function renderSubject() {
return renderWorkflowComponent(
<Workflow
nodes={baseNodes}
edges={baseEdges}
/>,
<ReactFlowProvider>
<Workflow
nodes={nodes}
edges={edges}
>
<ReactFlowEdgeBootstrap nodes={nodes} edges={edges} />
</Workflow>
</ReactFlowProvider>,
{
initialStoreState,
hooksStoreProps: {
configsMap: {
flowId: 'flow-1',
@ -319,75 +296,106 @@ function renderSubject() {
)
}
function ReactFlowEdgeBootstrap({ nodes, edges }: { nodes: Node[], edges: Edge[] }) {
const store = useStoreApi()
React.useEffect(() => {
store.setState({
edges,
width: 500,
height: 500,
nodeInternals: new Map(nodes.map(node => [node.id, node])),
})
reactFlowBridge.store = store
return () => {
reactFlowBridge.store = null
}
}, [edges, nodes, store])
return null
}
function getPane(container: HTMLElement) {
const pane = container.querySelector('.react-flow__pane') as HTMLElement | null
if (!pane)
throw new Error('Expected a rendered React Flow pane')
return pane
}
describe('Workflow edge event wiring', () => {
beforeEach(() => {
vi.clearAllMocks()
reactFlowState.lastProps = null
eventEmitterState.subscription = null
reactFlowBridge.store = null
})
it('should forward React Flow edge events to workflow handlers when emitted by the canvas', () => {
renderSubject()
it('should forward pane, node and edge-change events to workflow handlers when emitted by the canvas', async () => {
const { container } = renderSubject()
const pane = getPane(container)
fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse enter' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse leave' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit edges change' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit edge context menu' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit node context menu' }))
fireEvent.click(screen.getByRole('button', { name: 'Emit pane context menu' }))
act(() => {
fireEvent.contextMenu(screen.getByText('Workflow node node-1'), { clientX: 24, clientY: 48 })
fireEvent.contextMenu(pane, { clientX: 24, clientY: 48 })
})
expect(workflowHookMocks.handleEdgeEnter).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), baseEdges[0])
expect(workflowHookMocks.handleEdgeLeave).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), baseEdges[0])
expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(edgeChanges)
expect(workflowHookMocks.handleEdgeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), baseEdges[0])
expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), baseNodes[0])
expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}))
await waitFor(() => {
expect(reactFlowBridge.store?.getState().onEdgesChange).toBeTypeOf('function')
})
act(() => {
reactFlowBridge.store?.getState().onEdgesChange?.([{ id: 'edge-1', type: 'select', selected: true }])
})
await waitFor(() => {
expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 'edge-1', type: 'select' }),
]))
expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}), expect.objectContaining({ id: 'node-1' }))
expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({
clientX: 24,
clientY: 48,
}))
})
})
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', () => {
renderSubject()
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', async () => {
renderSubject({
edges: [
{
...baseEdges[0],
selected: true,
} as Edge,
],
})
expect(reactFlowState.lastProps?.deleteKeyCode).toBeNull()
act(() => {
fireEvent.keyDown(document.body, { key: 'Delete' })
})
await waitFor(() => {
expect(screen.getByText('Workflow node node-1')).toBeInTheDocument()
})
expect(workflowHookMocks.handleEdgesChange).not.toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 'edge-1', type: 'remove' }),
]))
})
it('should clear edgeMenu when workflow data updates remove the current edge', () => {
const { store } = renderWorkflowComponent(
<Workflow
nodes={baseNodes}
edges={baseEdges}
/>,
{
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'edge-1',
},
},
hooksStoreProps: {
configsMap: {
flowId: 'flow-1',
flowType: FlowType.appFlow,
fileSettings: {},
},
const { store } = renderSubject({
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'edge-1',
},
},
)
})
act(() => {
eventEmitterState.subscription?.({

View File

@ -4,10 +4,17 @@
import type { Shape } from '../store/workflow'
import { act, screen } from '@testing-library/react'
import * as React from 'react'
import { useNodes } from 'reactflow'
import { FlowType } from '@/types/common'
import { useHooksStore } from '../hooks-store/store'
import { useStore, useWorkflowStore } from '../store/workflow'
import { renderNodeComponent, renderWorkflowComponent } from './workflow-test-env'
import { createNode } from './fixtures'
import {
renderNodeComponent,
renderWorkflowComponent,
renderWorkflowFlowComponent,
renderWorkflowFlowHook,
} from './workflow-test-env'
// ---------------------------------------------------------------------------
// Test components that read from workflow contexts
@ -43,6 +50,12 @@ function NodeRenderer(props: { id: string, data: { title: string }, selected?: b
)
}
function FlowReader() {
const nodes = useNodes()
const showConfirm = useStore(s => s.showConfirm)
return React.createElement('div', { 'data-testid': 'flow-reader' }, `${nodes.length}:${showConfirm ? 'confirm' : 'clear'}`)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@ -134,3 +147,30 @@ describe('renderNodeComponent', () => {
expect(screen.getByTestId('node-store')).toHaveTextContent('test-node-1:hand')
})
})
describe('renderWorkflowFlowComponent', () => {
it('should provide both ReactFlow and Workflow contexts', () => {
renderWorkflowFlowComponent(React.createElement(FlowReader), {
nodes: [
createNode({ id: 'n-1' }),
createNode({ id: 'n-2' }),
],
initialStoreState: { showConfirm: { title: 'Hey', onConfirm: () => {} } },
})
expect(screen.getByTestId('flow-reader')).toHaveTextContent('2:confirm')
})
})
describe('renderWorkflowFlowHook', () => {
it('should render hooks inside a real ReactFlow provider', () => {
const { result } = renderWorkflowFlowHook(() => useNodes(), {
nodes: [
createNode({ id: 'flow-1' }),
],
})
expect(result.current).toHaveLength(1)
expect(result.current[0].id).toBe('flow-1')
})
})

View File

@ -65,9 +65,11 @@ import type { Shape as HooksStoreShape } from '../hooks-store/store'
import type { Shape } from '../store/workflow'
import type { Edge, Node, WorkflowRunningData } from '../types'
import type { WorkflowHistoryStoreApi } from '../workflow-history-store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, renderHook } from '@testing-library/react'
import isDeepEqual from 'fast-deep-equal'
import * as React from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { temporal } from 'zundo'
import { create } from 'zustand'
import { WorkflowContext } from '../context'
@ -154,6 +156,13 @@ function createWorkflowWrapper(
const historyCtxValue = historyConfig
? createTestHistoryStoreContext(historyConfig)
: undefined
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: React.ReactNode }) => {
let inner: React.ReactNode = children
@ -175,9 +184,13 @@ function createWorkflowWrapper(
}
return React.createElement(
WorkflowContext.Provider,
{ value: stores.store },
inner,
QueryClientProvider,
{ client: queryClient },
React.createElement(
WorkflowContext.Provider,
{ value: stores.store },
inner,
),
)
}
}
@ -240,6 +253,104 @@ export function renderWorkflowComponent(
return { ...renderResult, ...stores }
}
// ---------------------------------------------------------------------------
// renderWorkflowFlowComponent / renderWorkflowFlowHook — real ReactFlow wrappers
// ---------------------------------------------------------------------------
type WorkflowFlowOptions = WorkflowProviderOptions & {
nodes?: Node[]
edges?: Edge[]
reactFlowProps?: Omit<React.ComponentProps<typeof ReactFlow>, 'children' | 'nodes' | 'edges'>
canvasStyle?: React.CSSProperties
}
type WorkflowFlowComponentTestOptions = Omit<RenderOptions, 'wrapper'> & WorkflowFlowOptions
type WorkflowFlowHookTestOptions<P> = Omit<RenderHookOptions<P>, 'wrapper'> & WorkflowFlowOptions
function createWorkflowFlowWrapper(
stores: StoreInstances,
{
historyStore: historyConfig,
nodes = [],
edges = [],
reactFlowProps,
canvasStyle,
}: WorkflowFlowOptions,
) {
const workflowWrapper = createWorkflowWrapper(stores, historyConfig)
return ({ children }: { children: React.ReactNode }) => React.createElement(
workflowWrapper,
null,
React.createElement(
'div',
{ style: { width: 800, height: 600, ...canvasStyle } },
React.createElement(
ReactFlowProvider,
null,
React.createElement(ReactFlow, { fitView: true, ...reactFlowProps, nodes, edges }),
children,
),
),
)
}
export function renderWorkflowFlowComponent(
ui: React.ReactElement,
options?: WorkflowFlowComponentTestOptions,
): WorkflowComponentTestResult {
const {
initialStoreState,
hooksStoreProps,
historyStore,
nodes,
edges,
reactFlowProps,
canvasStyle,
...renderOptions
} = options ?? {}
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
const wrapper = createWorkflowFlowWrapper(stores, {
historyStore,
nodes,
edges,
reactFlowProps,
canvasStyle,
})
const renderResult = render(ui, { wrapper, ...renderOptions })
return { ...renderResult, ...stores }
}
export function renderWorkflowFlowHook<R, P = undefined>(
hook: (props: P) => R,
options?: WorkflowFlowHookTestOptions<P>,
): WorkflowHookTestResult<R, P> {
const {
initialStoreState,
hooksStoreProps,
historyStore,
nodes,
edges,
reactFlowProps,
canvasStyle,
...rest
} = options ?? {}
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
const wrapper = createWorkflowFlowWrapper(stores, {
historyStore,
nodes,
edges,
reactFlowProps,
canvasStyle,
})
const renderResult = renderHook(hook, { wrapper, ...rest })
return { ...renderResult, ...stores }
}
// ---------------------------------------------------------------------------
// renderNodeComponent — convenience wrapper for node components
// ---------------------------------------------------------------------------