- !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
+ onClick={handleUndo}
>
-
-
+
+
- !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
+ onClick={handleRedo}
>
-
-
+
+
diff --git a/web/app/components/workflow/header/view-history.tsx b/web/app/components/workflow/header/view-history.tsx
index 94963e29fc..162d46f8fe 100644
--- a/web/app/components/workflow/header/view-history.tsx
+++ b/web/app/components/workflow/header/view-history.tsx
@@ -73,15 +73,18 @@ const ViewHistory = ({
setOpen(v => !v)}>
{
withText && (
-
{t('common.showRunHistory', { ns: 'workflow' })}
-
+
)
}
{
@@ -89,14 +92,16 @@ const ViewHistory = ({
- {
onClearLogAndMessageModal?.()
}}
>
-
+
)
}
@@ -110,7 +115,9 @@ const ViewHistory = ({
>
{t('common.runHistory', { ns: 'workflow' })}
-
{
onClearLogAndMessageModal?.()
@@ -118,7 +125,7 @@ const ViewHistory = ({
}}
>
-
+
{
isLoading && (
diff --git a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx
index a76eba69ef..1843f77a52 100644
--- a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx
+++ b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx
@@ -1,54 +1,36 @@
import type { CommonNodeType } from '../../../types'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWorkflowComponent } from '../../../__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus } from '../../../types'
import NodeControl from './node-control'
const {
mockHandleNodeSelect,
- mockSetInitShowLastRunTab,
- mockSetPendingSingleRun,
mockCanRunBySingle,
} = vi.hoisted(() => ({
mockHandleNodeSelect: vi.fn(),
- mockSetInitShowLastRunTab: vi.fn(),
- mockSetPendingSingleRun: vi.fn(),
mockCanRunBySingle: vi.fn(() => true),
}))
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}))
+let mockPluginInstallLocked = false
-vi.mock('@/app/components/base/tooltip', () => ({
- default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
- {children}
- ),
-}))
-
-vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
- Stop: ({ className }: { className?: string }) => ,
-}))
-
-vi.mock('../../../hooks', () => ({
- useNodesInteractions: () => ({
- handleNodeSelect: mockHandleNodeSelect,
- }),
-}))
-
-vi.mock('@/app/components/workflow/store', () => ({
- useWorkflowStore: () => ({
- getState: () => ({
- setInitShowLastRunTab: mockSetInitShowLastRunTab,
- setPendingSingleRun: mockSetPendingSingleRun,
+vi.mock('../../../hooks', async () => {
+ const actual = await vi.importActual('../../../hooks')
+ return {
+ ...actual,
+ useNodesInteractions: () => ({
+ handleNodeSelect: mockHandleNodeSelect,
}),
- }),
-}))
+ }
+})
-vi.mock('../../../utils', () => ({
- canRunBySingle: mockCanRunBySingle,
-}))
+vi.mock('../../../utils', async () => {
+ const actual = await vi.importActual('../../../utils')
+ return {
+ ...actual,
+ canRunBySingle: mockCanRunBySingle,
+ }
+})
vi.mock('./panel-operator', () => ({
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
@@ -59,6 +41,16 @@ vi.mock('./panel-operator', () => ({
),
}))
+function NodeControlHarness({ id, data }: { id: string, data: CommonNodeType, selected?: boolean }) {
+ return (
+
+ )
+}
+
const makeData = (overrides: Partial = {}): CommonNodeType => ({
type: BlockEnum.Code,
title: 'Node',
@@ -73,65 +65,71 @@ const makeData = (overrides: Partial = {}): CommonNodeType => ({
describe('NodeControl', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockPluginInstallLocked = false
mockCanRunBySingle.mockReturnValue(true)
})
- it('should trigger a single run and show the hover control when plugins are not locked', () => {
- const { container } = render(
- ,
- )
+ // Run/stop behavior should be driven by the workflow store, not CSS classes.
+ describe('Single Run Actions', () => {
+ it('should trigger a single run through the workflow store', () => {
+ const { store } = renderWorkflowComponent(
+ ,
+ )
- const wrapper = container.firstChild as HTMLElement
- expect(wrapper.className).toContain('group-hover:flex')
- expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'panel.runThisStep')
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' }))
- fireEvent.click(screen.getByTestId('tooltip').parentElement!)
+ expect(store.getState().initShowLastRunTab).toBe(true)
+ expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-1', action: 'run' })
+ expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
+ })
- expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true)
- expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-1', action: 'run' })
- expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
+ it('should trigger stop when the node is already single-running', () => {
+ const { store } = renderWorkflowComponent(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }))
+
+ expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-2', action: 'stop' })
+ expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-2')
+ })
})
- it('should render the stop action, keep locked controls hidden by default, and stay open when panel operator opens', () => {
- const { container } = render(
- ,
- )
+ // Capability gating should hide the run control while leaving panel actions available.
+ describe('Availability', () => {
+ it('should keep the panel operator available when the plugin is install-locked', () => {
+ mockPluginInstallLocked = true
- const wrapper = container.firstChild as HTMLElement
- expect(wrapper.className).not.toContain('group-hover:flex')
- expect(wrapper.className).toContain('!flex')
- expect(screen.getByTestId('stop-icon')).toBeInTheDocument()
+ renderWorkflowComponent(
+ ,
+ )
- fireEvent.click(screen.getByTestId('stop-icon').parentElement!)
+ expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
+ })
- expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-2', action: 'stop' })
+ it('should hide the run control when single-node execution is not supported', () => {
+ mockCanRunBySingle.mockReturnValue(false)
- fireEvent.click(screen.getByRole('button', { name: 'open panel' }))
- expect(wrapper.className).toContain('!flex')
- })
+ renderWorkflowComponent(
+ ,
+ )
- it('should hide the run control when single-node execution is not supported', () => {
- mockCanRunBySingle.mockReturnValue(false)
-
- render(
- ,
- )
-
- expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'workflow.panel.runThisStep' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
+ })
})
})
diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx
index 1ae697dfc4..ba2a4d3f73 100644
--- a/web/app/components/workflow/nodes/_base/components/node-control.tsx
+++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx
@@ -1,8 +1,5 @@
import type { FC } from 'react'
import type { Node } from '../../../types'
-import {
- RiPlayLargeLine,
-} from '@remixicon/react'
import {
memo,
useCallback,
@@ -54,7 +51,9 @@ const NodeControl: FC = ({
>
{
canRunBySingle(data.type, isChildNode) && (
- {
const action = isSingleRunning ? 'stop' : 'run'
@@ -76,11 +75,11 @@ const NodeControl: FC = ({
popupContent={t('panel.runThisStep', { ns: 'workflow' })}
asChild={false}
>
-
+
)
}
-
+
)
}
({
mockHandleStatusCodeChange: vi.fn(),
mockGenerateWebhookUrl: vi.fn(),
+ mockHandleMethodChange: vi.fn(),
+ mockHandleContentTypeChange: vi.fn(),
+ mockHandleHeadersChange: vi.fn(),
+ mockHandleParamsChange: vi.fn(),
+ mockHandleBodyChange: vi.fn(),
+ mockHandleResponseBodyChange: vi.fn(),
}))
+const mockConfigState = {
+ readOnly: false,
+ inputs: {
+ method: 'POST',
+ webhook_url: 'https://example.com/webhook',
+ webhook_debug_url: '',
+ content_type: 'application/json',
+ headers: [],
+ params: [],
+ body: [],
+ status_code: 200,
+ response_body: 'ok',
+ variables: [],
+ },
+}
+
vi.mock('../use-config', () => ({
DEFAULT_STATUS_CODE: 200,
MAX_STATUS_CODE: 399,
normalizeStatusCode: (statusCode: number) => Math.min(Math.max(statusCode, 200), 399),
useConfig: () => ({
- readOnly: false,
- inputs: {
- method: 'POST',
- webhook_url: 'https://example.com/webhook',
- webhook_debug_url: '',
- content_type: 'application/json',
- headers: [],
- params: [],
- body: [],
- status_code: 200,
- response_body: '',
- },
- handleMethodChange: vi.fn(),
- handleContentTypeChange: vi.fn(),
- handleHeadersChange: vi.fn(),
- handleParamsChange: vi.fn(),
- handleBodyChange: vi.fn(),
+ readOnly: mockConfigState.readOnly,
+ inputs: mockConfigState.inputs,
+ handleMethodChange: mockHandleMethodChange,
+ handleContentTypeChange: mockHandleContentTypeChange,
+ handleHeadersChange: mockHandleHeadersChange,
+ handleParamsChange: mockHandleParamsChange,
+ handleBodyChange: mockHandleBodyChange,
handleStatusCodeChange: mockHandleStatusCodeChange,
- handleResponseBodyChange: vi.fn(),
+ handleResponseBodyChange: mockHandleResponseBodyChange,
generateWebhookUrl: mockGenerateWebhookUrl,
}),
}))
-vi.mock('@/app/components/base/input-with-copy', () => ({
- default: () => ,
-}))
-
-vi.mock('@/app/components/base/select', () => ({
- SimpleSelect: () => ,
-}))
-
-vi.mock('@/app/components/base/tooltip', () => ({
- default: ({ children }: { children: React.ReactNode }) => <>{children}>,
-}))
-
-vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
- default: ({ title, children }: { title: React.ReactNode, children: React.ReactNode }) => (
-
- ),
-}))
-
-vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
- default: () => ,
-}))
-
-vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
- default: () => ,
-}))
-
-vi.mock('../components/header-table', () => ({
- default: () => ,
-}))
-
-vi.mock('../components/parameter-table', () => ({
- default: () => ,
-}))
-
-vi.mock('../components/paragraph-input', () => ({
- default: () => ,
-}))
-
-vi.mock('../utils/render-output-vars', () => ({
- OutputVariablesContent: () => ,
-}))
+const getStatusCodeInput = () => {
+ return screen.getAllByDisplayValue('200')
+ .find(element => element.getAttribute('aria-hidden') !== 'true') as HTMLInputElement
+}
describe('WebhookTriggerPanel', () => {
const panelProps: NodePanelProps = {
@@ -100,7 +78,7 @@ describe('WebhookTriggerPanel', () => {
body: [],
async_mode: false,
status_code: 200,
- response_body: '',
+ response_body: 'ok',
variables: [],
},
panelProps: {} as PanelProps,
@@ -108,26 +86,65 @@ describe('WebhookTriggerPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockConfigState.readOnly = false
+ mockConfigState.inputs = {
+ method: 'POST',
+ webhook_url: 'https://example.com/webhook',
+ webhook_debug_url: '',
+ content_type: 'application/json',
+ headers: [],
+ params: [],
+ body: [],
+ status_code: 200,
+ response_body: 'ok',
+ variables: [],
+ }
})
- it('should update the status code when users enter a parseable value', () => {
- render()
+ describe('Rendering', () => {
+ it('should render the real panel fields without generating a new webhook url when one already exists', () => {
+ render()
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '201' } })
+ expect(screen.getByDisplayValue('https://example.com/webhook')).toBeInTheDocument()
+ expect(screen.getByText('application/json')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('ok')).toBeInTheDocument()
+ expect(mockGenerateWebhookUrl).not.toHaveBeenCalled()
+ })
- expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201)
+ it('should request a webhook url when the node is writable and missing one', async () => {
+ mockConfigState.inputs = {
+ ...mockConfigState.inputs,
+ webhook_url: '',
+ }
+
+ render()
+
+ await waitFor(() => {
+ expect(mockGenerateWebhookUrl).toHaveBeenCalledTimes(1)
+ })
+ })
})
- it('should ignore clear changes until the value is committed', () => {
- render()
+ describe('Status Code Input', () => {
+ it('should update the status code when users enter a parseable value', () => {
+ render()
- const input = screen.getByRole('textbox')
- fireEvent.change(input, { target: { value: '' } })
+ fireEvent.change(getStatusCodeInput(), { target: { value: '201' } })
- expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
+ expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201)
+ })
- fireEvent.blur(input)
+ it('should ignore clear changes until the value is committed', () => {
+ render()
- expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
+ const input = getStatusCodeInput()
+ fireEvent.change(input, { target: { value: '' } })
+
+ expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
+
+ fireEvent.blur(input)
+
+ expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
+ })
})
})
diff --git a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx
new file mode 100644
index 0000000000..ab7ec2ef0e
--- /dev/null
+++ b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx
@@ -0,0 +1,225 @@
+import type { ReactNode } from 'react'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import ReactFlow, { ReactFlowProvider } from 'reactflow'
+import { FlowType } from '@/types/common'
+import { BlockEnum } from '../../types'
+import AddBlock from '../add-block'
+
+type BlockSelectorMockProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ disabled: boolean
+ onSelect: (type: BlockEnum, pluginDefaultValue?: Record) => void
+ placement: string
+ offset: {
+ mainAxis: number
+ crossAxis: number
+ }
+ trigger: (open: boolean) => ReactNode
+ popupClassName: string
+ availableBlocksTypes: BlockEnum[]
+ showStartTab: boolean
+}
+
+const {
+ mockHandlePaneContextmenuCancel,
+ mockWorkflowStoreSetState,
+ mockGenerateNewNode,
+ mockGetNodeCustomTypeByNodeDataType,
+} = vi.hoisted(() => ({
+ mockHandlePaneContextmenuCancel: vi.fn(),
+ mockWorkflowStoreSetState: vi.fn(),
+ mockGenerateNewNode: vi.fn(({ type, data }: { type: string, data: Record }) => ({
+ newNode: {
+ id: 'generated-node',
+ type,
+ data,
+ },
+ })),
+ mockGetNodeCustomTypeByNodeDataType: vi.fn((type: string) => `${type}-custom`),
+}))
+
+let latestBlockSelectorProps: BlockSelectorMockProps | null = null
+let mockNodesReadOnly = false
+let mockIsChatMode = false
+let mockFlowType: FlowType = FlowType.appFlow
+
+const mockAvailableNextBlocks = [BlockEnum.Answer, BlockEnum.Code]
+const mockNodesMetaDataMap = {
+ [BlockEnum.Answer]: {
+ defaultValue: {
+ title: 'Answer',
+ desc: '',
+ type: BlockEnum.Answer,
+ },
+ },
+}
+
+vi.mock('@/app/components/workflow/block-selector', () => ({
+ default: (props: BlockSelectorMockProps) => {
+ latestBlockSelectorProps = props
+ return (
+
+ {props.trigger(props.open)}
+
+ )
+ },
+}))
+
+vi.mock('../../hooks', () => ({
+ useAvailableBlocks: () => ({
+ availableNextBlocks: mockAvailableNextBlocks,
+ }),
+ useIsChatMode: () => mockIsChatMode,
+ useNodesMetaData: () => ({
+ nodesMap: mockNodesMetaDataMap,
+ }),
+ useNodesReadOnly: () => ({
+ nodesReadOnly: mockNodesReadOnly,
+ }),
+ usePanelInteractions: () => ({
+ handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
+ }),
+}))
+
+vi.mock('../../hooks-store', () => ({
+ useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) =>
+ selector({ configsMap: { flowType: mockFlowType } }),
+}))
+
+vi.mock('../../store', () => ({
+ useWorkflowStore: () => ({
+ setState: mockWorkflowStoreSetState,
+ }),
+}))
+
+vi.mock('../../utils', () => ({
+ generateNewNode: mockGenerateNewNode,
+ getNodeCustomTypeByNodeDataType: mockGetNodeCustomTypeByNodeDataType,
+}))
+
+vi.mock('../tip-popup', () => ({
+ default: ({ children }: { children?: ReactNode }) => <>{children}>,
+}))
+
+const renderWithReactFlow = (nodes: Array<{ id: string, position: { x: number, y: number }, data: { type: BlockEnum } }>) => {
+ return render(
+ ,
+ )
+}
+
+describe('AddBlock', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ latestBlockSelectorProps = null
+ mockNodesReadOnly = false
+ mockIsChatMode = false
+ mockFlowType = FlowType.appFlow
+ })
+
+ // Rendering and selector configuration.
+ describe('Rendering', () => {
+ it('should pass the selector props for a writable app workflow', async () => {
+ renderWithReactFlow([])
+
+ await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
+
+ expect(screen.getByTestId('block-selector')).toBeInTheDocument()
+ expect(latestBlockSelectorProps).toMatchObject({
+ disabled: false,
+ availableBlocksTypes: mockAvailableNextBlocks,
+ showStartTab: true,
+ placement: 'right-start',
+ popupClassName: '!min-w-[256px]',
+ })
+ expect(latestBlockSelectorProps?.offset).toEqual({
+ mainAxis: 4,
+ crossAxis: -8,
+ })
+ })
+
+ it('should hide the start tab for chat mode and rag pipeline flows', async () => {
+ mockIsChatMode = true
+ const { rerender } = renderWithReactFlow([])
+
+ await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
+
+ expect(latestBlockSelectorProps?.showStartTab).toBe(false)
+
+ mockIsChatMode = false
+ mockFlowType = FlowType.ragPipeline
+ rerender(
+ ,
+ )
+
+ expect(latestBlockSelectorProps?.showStartTab).toBe(false)
+ })
+ })
+
+ // User interactions that bridge selector state and workflow state.
+ describe('User Interactions', () => {
+ it('should cancel the pane context menu when the selector closes', async () => {
+ renderWithReactFlow([])
+
+ await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
+
+ act(() => {
+ latestBlockSelectorProps?.onOpenChange(false)
+ })
+
+ expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should create a candidate node with an incremented title when a block is selected', async () => {
+ renderWithReactFlow([
+ { id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer } },
+ { id: 'node-2', position: { x: 80, y: 0 }, data: { type: BlockEnum.Answer } },
+ ])
+
+ await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
+
+ act(() => {
+ latestBlockSelectorProps?.onSelect(BlockEnum.Answer, { pluginId: 'plugin-1' })
+ })
+
+ expect(mockGetNodeCustomTypeByNodeDataType).toHaveBeenCalledWith(BlockEnum.Answer)
+ expect(mockGenerateNewNode).toHaveBeenCalledWith({
+ type: 'answer-custom',
+ data: {
+ title: 'Answer 3',
+ desc: '',
+ type: BlockEnum.Answer,
+ pluginId: 'plugin-1',
+ _isCandidate: true,
+ },
+ position: {
+ x: 0,
+ y: 0,
+ },
+ })
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
+ candidateNode: {
+ id: 'generated-node',
+ type: 'answer-custom',
+ data: {
+ title: 'Answer 3',
+ desc: '',
+ type: BlockEnum.Answer,
+ pluginId: 'plugin-1',
+ _isCandidate: true,
+ },
+ },
+ })
+ })
+ })
+})
diff --git a/web/app/components/workflow/operator/__tests__/control.spec.tsx b/web/app/components/workflow/operator/__tests__/control.spec.tsx
new file mode 100644
index 0000000000..053d61d1ce
--- /dev/null
+++ b/web/app/components/workflow/operator/__tests__/control.spec.tsx
@@ -0,0 +1,136 @@
+import type { ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ControlMode } from '../../types'
+import Control from '../control'
+
+type WorkflowStoreState = {
+ controlMode: ControlMode
+ maximizeCanvas: boolean
+}
+
+const {
+ mockHandleAddNote,
+ mockHandleLayout,
+ mockHandleModeHand,
+ mockHandleModePointer,
+ mockHandleToggleMaximizeCanvas,
+} = vi.hoisted(() => ({
+ mockHandleAddNote: vi.fn(),
+ mockHandleLayout: vi.fn(),
+ mockHandleModeHand: vi.fn(),
+ mockHandleModePointer: vi.fn(),
+ mockHandleToggleMaximizeCanvas: vi.fn(),
+}))
+
+let mockNodesReadOnly = false
+let mockStoreState: WorkflowStoreState
+
+vi.mock('../../hooks', () => ({
+ useNodesReadOnly: () => ({
+ nodesReadOnly: mockNodesReadOnly,
+ getNodesReadOnly: () => mockNodesReadOnly,
+ }),
+ useWorkflowCanvasMaximize: () => ({
+ handleToggleMaximizeCanvas: mockHandleToggleMaximizeCanvas,
+ }),
+ useWorkflowMoveMode: () => ({
+ handleModePointer: mockHandleModePointer,
+ handleModeHand: mockHandleModeHand,
+ }),
+ useWorkflowOrganize: () => ({
+ handleLayout: mockHandleLayout,
+ }),
+}))
+
+vi.mock('../hooks', () => ({
+ useOperator: () => ({
+ handleAddNote: mockHandleAddNote,
+ }),
+}))
+
+vi.mock('../../store', () => ({
+ useStore: (selector: (state: WorkflowStoreState) => unknown) => selector(mockStoreState),
+}))
+
+vi.mock('../add-block', () => ({
+ default: () => ,
+}))
+
+vi.mock('../more-actions', () => ({
+ default: () => ,
+}))
+
+vi.mock('../tip-popup', () => ({
+ default: ({
+ children,
+ title,
+ }: {
+ children?: ReactNode
+ title?: string
+ }) => {children}
,
+}))
+
+describe('Control', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockNodesReadOnly = false
+ mockStoreState = {
+ controlMode: ControlMode.Pointer,
+ maximizeCanvas: false,
+ }
+ })
+
+ // Rendering and visual states for control buttons.
+ describe('Rendering', () => {
+ it('should render the child action groups and highlight the active pointer mode', () => {
+ render()
+
+ expect(screen.getByTestId('add-block')).toBeInTheDocument()
+ expect(screen.getByTestId('more-actions')).toBeInTheDocument()
+ expect(screen.getByTestId('workflow.common.pointerMode').firstElementChild).toHaveClass('bg-state-accent-active')
+ expect(screen.getByTestId('workflow.common.handMode').firstElementChild).not.toHaveClass('bg-state-accent-active')
+ expect(screen.getByTestId('workflow.panel.maximize')).toBeInTheDocument()
+ })
+
+ it('should switch the maximize tooltip and active style when the canvas is maximized', () => {
+ mockStoreState = {
+ controlMode: ControlMode.Hand,
+ maximizeCanvas: true,
+ }
+
+ render()
+
+ expect(screen.getByTestId('workflow.common.handMode').firstElementChild).toHaveClass('bg-state-accent-active')
+ expect(screen.getByTestId('workflow.panel.minimize').firstElementChild).toHaveClass('bg-state-accent-active')
+ })
+ })
+
+ // User interactions exposed by the control bar.
+ describe('User Interactions', () => {
+ it('should trigger the note, mode, organize, and maximize handlers', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement)
+ fireEvent.click(screen.getByTestId('workflow.common.pointerMode').firstElementChild as HTMLElement)
+ fireEvent.click(screen.getByTestId('workflow.common.handMode').firstElementChild as HTMLElement)
+ fireEvent.click(screen.getByTestId('workflow.panel.organizeBlocks').firstElementChild as HTMLElement)
+ fireEvent.click(screen.getByTestId('workflow.panel.maximize').firstElementChild as HTMLElement)
+
+ expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
+ expect(mockHandleModePointer).toHaveBeenCalledTimes(1)
+ expect(mockHandleModeHand).toHaveBeenCalledTimes(1)
+ expect(mockHandleLayout).toHaveBeenCalledTimes(1)
+ expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
+ })
+
+ it('should block note creation when the workflow is read only', () => {
+ mockNodesReadOnly = true
+
+ render()
+
+ fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement)
+
+ expect(mockHandleAddNote).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx
new file mode 100644
index 0000000000..ddefe60b7e
--- /dev/null
+++ b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx
@@ -0,0 +1,323 @@
+import type { Shape as HooksStoreShape } from '../../hooks-store/store'
+import type { RunFile } from '../../types'
+import type { FileUpload } from '@/app/components/base/features/types'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ReactFlow, { ReactFlowProvider } from 'reactflow'
+import { TransferMethod } from '@/types/app'
+import { FlowType } from '@/types/common'
+import { createStartNode } from '../../__tests__/fixtures'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import { InputVarType, WorkflowRunningStatus } from '../../types'
+import InputsPanel from '../inputs-panel'
+
+const mockCheckInputsForm = vi.fn()
+const mockNotify = vi.fn()
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({}),
+}))
+
+vi.mock('@/app/components/base/toast/context', () => ({
+ useToastContext: () => ({
+ notify: mockNotify,
+ close: vi.fn(),
+ }),
+}))
+
+vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({
+ useCheckInputsForms: () => ({
+ checkInputsForm: mockCheckInputsForm,
+ }),
+}))
+
+const fileSettingsWithImage = {
+ enabled: true,
+ image: {
+ enabled: true,
+ },
+ allowed_file_upload_methods: [TransferMethod.remote_url],
+ number_limits: 3,
+ image_file_size_limit: 10,
+} satisfies FileUpload & { image_file_size_limit: number }
+
+const uploadedRunFile = {
+ transfer_method: TransferMethod.remote_url,
+ upload_file_id: 'file-2',
+} as unknown as RunFile
+
+const uploadingRunFile = {
+ transfer_method: TransferMethod.local_file,
+} as unknown as RunFile
+
+const createHooksStoreProps = (
+ overrides: Partial = {},
+): Partial => ({
+ handleRun: vi.fn(),
+ configsMap: {
+ flowId: 'flow-1',
+ flowType: FlowType.appFlow,
+ fileSettings: fileSettingsWithImage,
+ },
+ ...overrides,
+})
+
+const renderInputsPanel = (
+ startNode: ReturnType,
+ options?: Parameters[1],
+) => {
+ return renderWorkflowComponent(
+
+
+
+
+
+
,
+ options,
+ )
+}
+
+describe('InputsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckInputsForm.mockReturnValue(true)
+ })
+
+ describe('Rendering', () => {
+ it('should render current inputs, defaults, and the image uploader from the start node', () => {
+ renderInputsPanel(
+ createStartNode({
+ data: {
+ variables: [
+ {
+ type: InputVarType.textInput,
+ variable: 'question',
+ label: 'Question',
+ required: true,
+ default: 'default question',
+ },
+ {
+ type: InputVarType.number,
+ variable: 'count',
+ label: 'Count',
+ required: false,
+ default: '2',
+ },
+ ],
+ },
+ }),
+ {
+ initialStoreState: {
+ inputs: {
+ question: 'overridden question',
+ },
+ },
+ hooksStoreProps: createHooksStoreProps(),
+ },
+ )
+
+ expect(screen.getByDisplayValue('overridden question')).toHaveFocus()
+ expect(screen.getByRole('spinbutton')).toHaveValue(2)
+ expect(screen.getByText('common.imageUploader.pasteImageLink')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should update workflow inputs and image files when users edit the form', async () => {
+ const user = userEvent.setup()
+ const { store } = renderInputsPanel(
+ createStartNode({
+ data: {
+ variables: [
+ {
+ type: InputVarType.textInput,
+ variable: 'question',
+ label: 'Question',
+ required: true,
+ },
+ ],
+ },
+ }),
+ {
+ hooksStoreProps: createHooksStoreProps(),
+ },
+ )
+
+ await user.type(screen.getByPlaceholderText('Question'), 'changed question')
+ expect(store.getState().inputs).toEqual({ question: 'changed question' })
+
+ await user.click(screen.getByText('common.imageUploader.pasteImageLink'))
+ await user.type(
+ await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder'),
+ 'https://example.com/image.png',
+ )
+ await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
+
+ await waitFor(() => {
+ expect(store.getState().files).toEqual([{
+ type: 'image',
+ transfer_method: TransferMethod.remote_url,
+ url: 'https://example.com/image.png',
+ upload_file_id: '',
+ }])
+ })
+ })
+
+ it('should not start a run when input validation fails', async () => {
+ const user = userEvent.setup()
+ mockCheckInputsForm.mockReturnValue(false)
+ const onRun = vi.fn()
+ const handleRun = vi.fn()
+
+ renderWorkflowComponent(
+
+
+
+
+
+
,
+ {
+ hooksStoreProps: createHooksStoreProps({ handleRun }),
+ },
+ )
+
+ await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
+
+ expect(mockCheckInputsForm).toHaveBeenCalledWith(
+ { question: 'default question' },
+ expect.arrayContaining([
+ expect.objectContaining({ variable: 'question' }),
+ expect.objectContaining({ variable: '__image' }),
+ ]),
+ )
+ expect(onRun).not.toHaveBeenCalled()
+ expect(handleRun).not.toHaveBeenCalled()
+ })
+
+ it('should start a run with processed inputs when validation succeeds', async () => {
+ const user = userEvent.setup()
+ const onRun = vi.fn()
+ const handleRun = vi.fn()
+
+ renderWorkflowComponent(
+
+
+
+
+
+
,
+ {
+ initialStoreState: {
+ inputs: {
+ question: 'run this',
+ confirmed: 'truthy',
+ },
+ files: [uploadedRunFile],
+ },
+ hooksStoreProps: createHooksStoreProps({
+ handleRun,
+ configsMap: {
+ flowId: 'flow-1',
+ flowType: FlowType.appFlow,
+ fileSettings: {
+ enabled: false,
+ },
+ },
+ }),
+ },
+ )
+
+ await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
+
+ expect(onRun).toHaveBeenCalledTimes(1)
+ expect(handleRun).toHaveBeenCalledWith({
+ inputs: {
+ question: 'run this',
+ confirmed: true,
+ },
+ files: [uploadedRunFile],
+ })
+ })
+ })
+
+ describe('Disabled States', () => {
+ it('should disable the run button while a local file is still uploading', () => {
+ renderInputsPanel(createStartNode(), {
+ initialStoreState: {
+ files: [uploadingRunFile],
+ },
+ hooksStoreProps: createHooksStoreProps({
+ configsMap: {
+ flowId: 'flow-1',
+ flowType: FlowType.appFlow,
+ fileSettings: {
+ enabled: false,
+ },
+ },
+ }),
+ })
+
+ expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
+ })
+
+ it('should disable the run button while the workflow is already running', () => {
+ renderInputsPanel(createStartNode(), {
+ initialStoreState: {
+ workflowRunningData: {
+ result: {
+ status: WorkflowRunningStatus.Running,
+ inputs_truncated: false,
+ process_data_truncated: false,
+ outputs_truncated: false,
+ },
+ tracing: [],
+ },
+ },
+ hooksStoreProps: createHooksStoreProps(),
+ })
+
+ expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
+ })
+ })
+})
diff --git a/web/app/components/workflow/panel/__tests__/record.spec.tsx b/web/app/components/workflow/panel/__tests__/record.spec.tsx
new file mode 100644
index 0000000000..1d07098427
--- /dev/null
+++ b/web/app/components/workflow/panel/__tests__/record.spec.tsx
@@ -0,0 +1,163 @@
+import type { WorkflowRunDetailResponse } from '@/models/log'
+import { act, screen } from '@testing-library/react'
+import { createEdge, createNode } from '../../__tests__/fixtures'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import Record from '../record'
+
+const mockHandleUpdateWorkflowCanvas = vi.fn()
+const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => finishedAt ? ' (Finished)' : ' (Running)')
+
+let latestGetResultCallback: ((res: WorkflowRunDetailResponse) => void) | undefined
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useWorkflowUpdate: () => ({
+ handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
+ }),
+}))
+
+vi.mock('@/app/components/workflow/run', () => ({
+ default: ({
+ runDetailUrl,
+ tracingListUrl,
+ getResultCallback,
+ }: {
+ runDetailUrl: string
+ tracingListUrl: string
+ getResultCallback: (res: WorkflowRunDetailResponse) => void
+ }) => {
+ latestGetResultCallback = getResultCallback
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/utils', () => ({
+ formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
+}))
+
+const createRunDetail = (overrides: Partial = {}): WorkflowRunDetailResponse => ({
+ id: 'run-1',
+ version: '1',
+ graph: {
+ nodes: [],
+ edges: [],
+ },
+ inputs: '{}',
+ inputs_truncated: false,
+ status: 'succeeded',
+ outputs: '{}',
+ outputs_truncated: false,
+ total_steps: 1,
+ created_by_role: 'account',
+ created_at: 1,
+ finished_at: 2,
+ ...overrides,
+})
+
+describe('Record', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ latestGetResultCallback = undefined
+ })
+
+ it('renders the run title and passes run and trace URLs to the run panel', () => {
+ const getWorkflowRunAndTraceUrl = vi.fn((runId?: string) => ({
+ runUrl: `/runs/${runId}`,
+ traceUrl: `/traces/${runId}`,
+ }))
+
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'run-1',
+ status: 'succeeded',
+ finished_at: 1700000000000,
+ },
+ },
+ hooksStoreProps: {
+ getWorkflowRunAndTraceUrl,
+ },
+ })
+
+ expect(screen.getByText('Test Run (Finished)')).toBeInTheDocument()
+ expect(screen.getByTestId('run')).toHaveAttribute('data-run-detail-url', '/runs/run-1')
+ expect(screen.getByTestId('run')).toHaveAttribute('data-tracing-list-url', '/traces/run-1')
+ expect(getWorkflowRunAndTraceUrl).toHaveBeenCalledTimes(2)
+ expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(1, 'run-1')
+ expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(2, 'run-1')
+ expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1700000000000)
+ })
+
+ it('updates the workflow canvas with a fallback viewport when the response omits one', () => {
+ const nodes = [createNode({ id: 'node-1' })]
+ const edges = [createEdge({ id: 'edge-1' })]
+
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'run-1',
+ status: 'succeeded',
+ },
+ },
+ hooksStoreProps: {
+ getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
+ },
+ })
+
+ expect(latestGetResultCallback).toBeDefined()
+
+ act(() => {
+ latestGetResultCallback?.(createRunDetail({
+ graph: {
+ nodes,
+ edges,
+ },
+ }))
+ })
+
+ expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
+ nodes,
+ edges,
+ viewport: { x: 0, y: 0, zoom: 1 },
+ })
+ })
+
+ it('uses the response viewport when one is available', () => {
+ const nodes = [createNode({ id: 'node-1' })]
+ const edges = [createEdge({ id: 'edge-1' })]
+ const viewport = { x: 12, y: 24, zoom: 0.75 }
+
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'run-1',
+ status: 'succeeded',
+ },
+ },
+ hooksStoreProps: {
+ getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
+ },
+ })
+
+ act(() => {
+ latestGetResultCallback?.(createRunDetail({
+ graph: {
+ nodes,
+ edges,
+ viewport,
+ },
+ }))
+ })
+
+ expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
+ nodes,
+ edges,
+ viewport,
+ })
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/meta.spec.tsx b/web/app/components/workflow/run/__tests__/meta.spec.tsx
new file mode 100644
index 0000000000..2a1a4f4b1a
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/meta.spec.tsx
@@ -0,0 +1,68 @@
+import { render, screen } from '@testing-library/react'
+import Meta from '../meta'
+
+const mockFormatTime = vi.fn((value: number) => `formatted:${value}`)
+
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+describe('Meta', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders loading placeholders while the run is in progress', () => {
+ const { container } = render()
+
+ expect(container.querySelectorAll('.bg-text-quaternary')).toHaveLength(6)
+ expect(screen.queryByText('SUCCESS')).not.toBeInTheDocument()
+ expect(screen.queryByText('runLog.meta.steps')).toBeInTheDocument()
+ })
+
+ it.each([
+ ['succeeded', 'SUCCESS'],
+ ['partial-succeeded', 'PARTIAL SUCCESS'],
+ ['exception', 'EXCEPTION'],
+ ['failed', 'FAIL'],
+ ['stopped', 'STOP'],
+ ['paused', 'PENDING'],
+ ] as const)('renders the %s status label', (status, label) => {
+ render()
+
+ expect(screen.getByText(label)).toBeInTheDocument()
+ })
+
+ it('renders explicit metadata values and hides steps when requested', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Alice')).toBeInTheDocument()
+ expect(screen.getByText('formatted:1700000000000')).toBeInTheDocument()
+ expect(screen.getByText('1.235s')).toBeInTheDocument()
+ expect(screen.getByText('42 Tokens')).toBeInTheDocument()
+ expect(screen.queryByText('Run Steps')).not.toBeInTheDocument()
+ expect(mockFormatTime).toHaveBeenCalledWith(1700000000000, expect.any(String))
+ })
+
+ it('falls back to default values when metadata is missing', () => {
+ render()
+
+ expect(screen.getByText('N/A')).toBeInTheDocument()
+ expect(screen.getAllByText('-')).toHaveLength(2)
+ expect(screen.getByText('0 Tokens')).toBeInTheDocument()
+ expect(screen.getByText('runLog.meta.steps').parentElement).toHaveTextContent('1')
+ expect(mockFormatTime).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/output-panel.spec.tsx b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx
new file mode 100644
index 0000000000..34b13011ed
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx
@@ -0,0 +1,137 @@
+import type { FileEntity } from '@/app/components/base/file-uploader/types'
+import type { FileResponse } from '@/types/workflow'
+import { render, screen } from '@testing-library/react'
+import { TransferMethod } from '@/types/app'
+import OutputPanel from '../output-panel'
+
+type FileOutput = FileResponse & { dify_model_identity: '__dify__file__' }
+
+vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/base/file-uploader', () => ({
+ FileList: ({ files }: { files: FileEntity[] }) => (
+ {files.map(file => file.name).join(', ')}
+ ),
+}))
+
+vi.mock('@/app/components/base/markdown', () => ({
+ Markdown: ({ content }: { content: string }) => {content}
,
+}))
+
+vi.mock('@/app/components/workflow/run/status-container', () => ({
+ default: ({ status, children }: { status: string, children?: React.ReactNode }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
+ default: ({
+ language,
+ value,
+ height,
+ }: {
+ language: string
+ value: string
+ height?: number
+ }) => (
+
+ {value}
+
+ ),
+}))
+
+const createFileOutput = (overrides: Partial = {}): FileOutput => ({
+ dify_model_identity: '__dify__file__',
+ related_id: 'file-1',
+ extension: 'pdf',
+ filename: 'report.pdf',
+ size: 128,
+ mime_type: 'application/pdf',
+ transfer_method: TransferMethod.local_file,
+ type: 'document',
+ url: 'https://example.com/report.pdf',
+ upload_file_id: 'upload-1',
+ remote_url: '',
+ ...overrides,
+})
+
+describe('OutputPanel', () => {
+ it('renders the loading animation while the workflow is running', () => {
+ render()
+
+ expect(screen.getByTestId('loading-anim')).toBeInTheDocument()
+ })
+
+ it('renders the failed status container when there is an error', () => {
+ render()
+
+ expect(screen.getByTestId('status-container')).toHaveAttribute('data-status', 'failed')
+ expect(screen.getByText('Execution failed')).toBeInTheDocument()
+ })
+
+ it('renders the no-output placeholder when there are no outputs', () => {
+ render()
+
+ expect(screen.getByTestId('markdown')).toHaveTextContent('No Output')
+ })
+
+ it('renders a plain text output as markdown', () => {
+ render()
+
+ expect(screen.getByTestId('markdown')).toHaveTextContent('Hello Dify')
+ })
+
+ it('renders array text outputs as joined markdown content', () => {
+ render()
+
+ expect(screen.getByTestId('markdown')).toHaveTextContent(/Line 1\s+Line 2/)
+ })
+
+ it('renders a file list for a single file output', () => {
+ render()
+
+ expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf')
+ })
+
+ it('renders a file list for an array of file outputs', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf, summary.md')
+ })
+
+ it('renders structured outputs inside the code editor when height is available', () => {
+ render()
+
+ expect(screen.getByTestId('code-editor')).toHaveAttribute('data-language', 'json')
+ expect(screen.getByTestId('code-editor')).toHaveAttribute('data-height', '92')
+ expect(screen.getByTestId('code-editor')).toHaveAttribute('data-value', `{
+ "answer": "hello",
+ "score": 1
+}`)
+ })
+
+ it('skips the code editor when structured outputs have no positive height', () => {
+ render()
+
+ expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/result-text.spec.tsx b/web/app/components/workflow/run/__tests__/result-text.spec.tsx
new file mode 100644
index 0000000000..9b0827c2f0
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/result-text.spec.tsx
@@ -0,0 +1,88 @@
+import type { FileEntity } from '@/app/components/base/file-uploader/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { TransferMethod } from '@/types/app'
+import ResultText from '../result-text'
+
+vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/base/file-uploader', () => ({
+ FileList: ({ files }: { files: FileEntity[] }) => (
+ {files.map(file => file.name).join(', ')}
+ ),
+}))
+
+vi.mock('@/app/components/base/markdown', () => ({
+ Markdown: ({ content }: { content: string }) => {content}
,
+}))
+
+vi.mock('@/app/components/workflow/run/status-container', () => ({
+ default: ({ status, children }: { status: string, children?: React.ReactNode }) => (
+ {children}
+ ),
+}))
+
+describe('ResultText', () => {
+ it('renders the loading animation while waiting for a text result', () => {
+ render()
+
+ expect(screen.getByTestId('loading-anim')).toBeInTheDocument()
+ })
+
+ it('renders the error state when the run fails', () => {
+ render()
+
+ expect(screen.getByTestId('status-container')).toHaveAttribute('data-status', 'failed')
+ expect(screen.getByText('Run failed')).toBeInTheDocument()
+ })
+
+ it('renders the empty-state call to action and forwards clicks', () => {
+ const onClick = vi.fn()
+ render()
+
+ expect(screen.getByText('runLog.resultEmpty.title')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('runLog.resultEmpty.link'))
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not render the empty state for paused runs', () => {
+ render()
+
+ expect(screen.queryByText('runLog.resultEmpty.title')).not.toBeInTheDocument()
+ })
+
+ it('renders markdown content when text outputs are available', () => {
+ render()
+
+ expect(screen.getByTestId('markdown')).toHaveTextContent('hello workflow')
+ })
+
+ it('renders file groups when file outputs are available', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('attachments')).toBeInTheDocument()
+ expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf')
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/status.spec.tsx b/web/app/components/workflow/run/__tests__/status.spec.tsx
new file mode 100644
index 0000000000..25d3ceb278
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/status.spec.tsx
@@ -0,0 +1,131 @@
+import type { WorkflowPausedDetailsResponse } from '@/models/log'
+import { render, screen } from '@testing-library/react'
+import Status from '../status'
+
+const mockDocLink = vi.fn((path: string) => `https://docs.example.com${path}`)
+const mockUseWorkflowPausedDetails = vi.fn()
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => mockDocLink,
+}))
+
+vi.mock('@/service/use-log', () => ({
+ useWorkflowPausedDetails: (params: { workflowRunId: string, enabled?: boolean }) => mockUseWorkflowPausedDetails(params),
+}))
+
+const createPausedDetails = (overrides: Partial = {}): WorkflowPausedDetailsResponse => ({
+ paused_at: '2026-03-18T00:00:00Z',
+ paused_nodes: [],
+ ...overrides,
+})
+
+describe('Status', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseWorkflowPausedDetails.mockReturnValue({ data: undefined })
+ })
+
+ it('renders the running status and loading placeholders', () => {
+ render()
+
+ expect(screen.getByText('Running')).toBeInTheDocument()
+ expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(2)
+ expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
+ workflowRunId: 'run-1',
+ enabled: false,
+ })
+ })
+
+ it('renders the listening label when the run is waiting for input', () => {
+ render()
+
+ expect(screen.getByText('Listening')).toBeInTheDocument()
+ })
+
+ it('renders succeeded metadata values', () => {
+ render()
+
+ expect(screen.getByText('SUCCESS')).toBeInTheDocument()
+ expect(screen.getByText('1.234s')).toBeInTheDocument()
+ expect(screen.getByText('8 Tokens')).toBeInTheDocument()
+ })
+
+ it('renders stopped fallbacks when time and tokens are missing', () => {
+ render()
+
+ expect(screen.getByText('STOP')).toBeInTheDocument()
+ expect(screen.getByText('-')).toBeInTheDocument()
+ expect(screen.getByText('0 Tokens')).toBeInTheDocument()
+ })
+
+ it('renders failed details and the partial-success exception tip', () => {
+ render()
+
+ expect(screen.getByText('FAIL')).toBeInTheDocument()
+ expect(screen.getByText('Something broke')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":2}')).toBeInTheDocument()
+ })
+
+ it('renders the partial-succeeded warning summary', () => {
+ render()
+
+ expect(screen.getByText('PARTIAL SUCCESS')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":3}')).toBeInTheDocument()
+ })
+
+ it('renders the exception learn-more link', () => {
+ render()
+
+ const learnMoreLink = screen.getByRole('link', { name: 'workflow.common.learnMore' })
+
+ expect(screen.getByText('EXCEPTION')).toBeInTheDocument()
+ expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
+ expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/error-type')
+ })
+
+ it('renders paused placeholders when pause details have not loaded yet', () => {
+ render()
+
+ expect(screen.getByText('PENDING')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.humanInput.log.reason')).toBeInTheDocument()
+ expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(3)
+ expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
+ workflowRunId: 'run-3',
+ enabled: true,
+ })
+ })
+
+ it('renders paused human-input reasons and backstage URLs', () => {
+ mockUseWorkflowPausedDetails.mockReturnValue({
+ data: createPausedDetails({
+ paused_nodes: [
+ {
+ node_id: 'node-1',
+ node_title: 'Need review',
+ pause_type: {
+ type: 'human_input',
+ form_id: 'form-1',
+ backstage_input_url: 'https://example.com/a',
+ },
+ },
+ {
+ node_id: 'node-2',
+ node_title: 'Need review 2',
+ pause_type: {
+ type: 'human_input',
+ form_id: 'form-2',
+ backstage_input_url: 'https://example.com/b',
+ },
+ },
+ ],
+ }),
+ })
+
+ render()
+
+ expect(screen.getByText('workflow.nodes.humanInput.log.reasonContent')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.humanInput.log.backstageInputURL')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'https://example.com/a' })).toHaveAttribute('href', 'https://example.com/a')
+ expect(screen.getByRole('link', { name: 'https://example.com/b' })).toHaveAttribute('href', 'https://example.com/b')
+ })
+})
diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx
new file mode 100644
index 0000000000..b4e06676cd
--- /dev/null
+++ b/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx
@@ -0,0 +1,84 @@
+import type { NodeProps } from 'reactflow'
+import type { CommonNodeType } from '@/app/components/workflow/types'
+import { render, screen, waitFor } from '@testing-library/react'
+import ReactFlow, { ReactFlowProvider } from 'reactflow'
+import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
+import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
+import ErrorHandleOnNode from '../error-handle-on-node'
+
+const createNodeData = (overrides: Partial = {}): CommonNodeType => ({
+ type: BlockEnum.Code,
+ title: 'Node',
+ desc: '',
+ ...overrides,
+})
+
+const ErrorNode = ({ id, data }: NodeProps) => (
+
+
+
+)
+
+const renderErrorNode = (data: CommonNodeType) => {
+ return render(
+
+
+
+
+
,
+ )
+}
+
+describe('ErrorHandleOnNode', () => {
+ // Empty and default-value states.
+ describe('Rendering', () => {
+ it('should render nothing when the node has no error strategy', () => {
+ const { container } = renderErrorNode(createNodeData())
+
+ expect(screen.queryByText('workflow.common.onFailure')).not.toBeInTheDocument()
+ expect(container.querySelector('.react-flow__handle')).not.toBeInTheDocument()
+ })
+
+ it('should render the default-value label', async () => {
+ renderErrorNode(createNodeData({ error_strategy: ErrorHandleTypeEnum.defaultValue }))
+
+ await waitFor(() => expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument())
+ expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument()
+ })
+ })
+
+ // Fail-branch behavior and warning styling.
+ describe('Effects', () => {
+ it('should render the fail-branch source handle', async () => {
+ const { container } = renderErrorNode(createNodeData({ error_strategy: ErrorHandleTypeEnum.failBranch }))
+
+ await waitFor(() => expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument())
+ expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()
+ expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch)
+ })
+
+ it('should add warning styles when the node is in exception status', async () => {
+ const { container } = renderErrorNode(createNodeData({
+ error_strategy: ErrorHandleTypeEnum.defaultValue,
+ _runningStatus: NodeRunningStatus.Exception,
+ }))
+
+ await waitFor(() => expect(container.querySelector('.bg-state-warning-hover')).toBeInTheDocument())
+ expect(container.querySelector('.bg-state-warning-hover')).toHaveClass('border-components-badge-status-light-warning-halo')
+ expect(container.querySelector('.text-text-warning')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx
new file mode 100644
index 0000000000..a354ee9afb
--- /dev/null
+++ b/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx
@@ -0,0 +1,130 @@
+import type { NodeProps } from 'reactflow'
+import type { CommonNodeType } from '@/app/components/workflow/types'
+import { render, waitFor } from '@testing-library/react'
+import ReactFlow, { ReactFlowProvider } from 'reactflow'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { NodeSourceHandle, NodeTargetHandle } from '../node-handle'
+
+const createNodeData = (overrides: Partial = {}): CommonNodeType => ({
+ type: BlockEnum.Code,
+ title: 'Node',
+ desc: '',
+ ...overrides,
+})
+
+const TargetHandleNode = ({ id, data }: NodeProps) => (
+
+
+
+)
+
+const SourceHandleNode = ({ id, data }: NodeProps) => (
+
+
+
+)
+
+const renderFlowNode = (type: 'targetNode' | 'sourceNode', data: CommonNodeType) => {
+ return render(
+
+
+
+
+
,
+ )
+}
+
+describe('node-handle', () => {
+ // Target handle states and visibility rules.
+ describe('NodeTargetHandle', () => {
+ it('should hide the connection indicator when the target handle is not connected', async () => {
+ const { container } = renderFlowNode('targetNode', createNodeData())
+
+ await waitFor(() => expect(container.querySelector('.target-marker')).toBeInTheDocument())
+
+ const handle = container.querySelector('.target-marker')
+
+ expect(handle).toHaveAttribute('data-handleid', 'target-1')
+ expect(handle).toHaveClass('after:opacity-0')
+ })
+
+ it('should merge custom classes and hide start-like nodes completely', async () => {
+ const { container } = render(
+
+
+ ) => (
+
+
+
+ ),
+ }}
+ />
+
+
,
+ )
+
+ await waitFor(() => expect(container.querySelector('.custom-target')).toBeInTheDocument())
+
+ const handle = container.querySelector('.custom-target')
+
+ expect(handle).toHaveClass('opacity-0')
+ expect(handle).toHaveClass('custom-target')
+ })
+ })
+
+ // Source handle connection state.
+ describe('NodeSourceHandle', () => {
+ it('should keep the source indicator visible when the handle is connected', async () => {
+ const { container } = renderFlowNode('sourceNode', createNodeData({ _connectedSourceHandleIds: ['source-1'] }))
+
+ await waitFor(() => expect(container.querySelector('.source-marker')).toBeInTheDocument())
+
+ const handle = container.querySelector('.source-marker')
+
+ expect(handle).toHaveAttribute('data-handleid', 'source-1')
+ expect(handle).not.toHaveClass('after:opacity-0')
+ })
+ })
+})
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 6671296efa..a678b53eba 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -6602,11 +6602,6 @@
"count": 1
}
},
- "app/components/workflow/header/scroll-to-selected-node-button.tsx": {
- "tailwindcss/enforce-consistent-class-order": {
- "count": 1
- }
- },
"app/components/workflow/header/test-run-menu.tsx": {
"no-restricted-imports": {
"count": 1