Compare commits

...

6 Commits

Author SHA1 Message Date
CodingOnStar
37da9fde67 test(workflow): add unit tests for NoteEditor context and plugins 2026-03-18 17:29:45 +08:00
CodingOnStar
1a043c8406 test(workflow): add unit tests for NoteNode and NoteEditor toolbar components 2026-03-18 17:05:18 +08:00
CodingOnStar
509b8dd1b7 test(workflow): add test coverage for variable inspect, operator and preview 2026-03-18 16:57:30 +08:00
Coding On Star
db4deb1d6b test(workflow): reorganize specs into __tests__ and align with shared test infrastructure (#33625)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-18 16:40:28 +08:00
wangxiaolei
387e5a345f fix(api): make CreatorUserRole accept both end-user and end_user (#33638)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-18 14:54:12 +08:00
-LAN-
116cc22019 fix: clarify webhook debug endpoint behavior (#33597) 2026-03-18 14:28:33 +08:00
60 changed files with 5150 additions and 206 deletions

View File

@@ -70,7 +70,14 @@ def handle_webhook(webhook_id: str):
@bp.route("/webhook-debug/<string:webhook_id>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
def handle_webhook_debug(webhook_id: str):
"""Handle webhook debug calls without triggering production workflow execution."""
"""Handle webhook debug calls without triggering production workflow execution.
The debug webhook endpoint is only for draft inspection flows. It never enqueues
Celery work for the published workflow; instead it dispatches an in-memory debug
event to an active Variable Inspector listener. Returning a clear error when no
listener is registered prevents a misleading 200 response for requests that are
effectively dropped.
"""
try:
webhook_trigger, _, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id, is_debug=True)
if error:
@@ -94,11 +101,32 @@ def handle_webhook_debug(webhook_id: str):
"method": webhook_data.get("method"),
},
)
TriggerDebugEventBus.dispatch(
dispatch_count = TriggerDebugEventBus.dispatch(
tenant_id=webhook_trigger.tenant_id,
event=event,
pool_key=pool_key,
)
if dispatch_count == 0:
logger.warning(
"Webhook debug request dropped without an active listener for webhook %s (tenant=%s, app=%s, node=%s)",
webhook_trigger.webhook_id,
webhook_trigger.tenant_id,
webhook_trigger.app_id,
webhook_trigger.node_id,
)
return (
jsonify(
{
"error": "No active debug listener",
"message": (
"The webhook debug URL only works while the Variable Inspector is listening. "
"Use the published webhook URL to execute the workflow in Celery."
),
"execution_url": webhook_trigger.webhook_url,
}
),
409,
)
response_data, status_code = WebhookService.generate_webhook_response(node_config)
return jsonify(response_data), status_code

View File

@@ -11,6 +11,13 @@ class CreatorUserRole(StrEnum):
ACCOUNT = "account"
END_USER = "end_user"
@classmethod
def _missing_(cls, value):
if value == "end-user":
return cls.END_USER
else:
return super()._missing_(value)
class WorkflowRunTriggeredFrom(StrEnum):
DEBUGGING = "debugging"

View File

@@ -23,6 +23,7 @@ def mock_jsonify():
class DummyWebhookTrigger:
webhook_id = "wh-1"
webhook_url = "http://localhost:5001/triggers/webhook/wh-1"
tenant_id = "tenant-1"
app_id = "app-1"
node_id = "node-1"
@@ -104,7 +105,32 @@ class TestHandleWebhookDebug:
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
@patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
@patch.object(module.TriggerDebugEventBus, "dispatch")
@patch.object(module.TriggerDebugEventBus, "dispatch", return_value=0)
def test_debug_requires_active_listener(
self,
mock_dispatch,
mock_build_inputs,
mock_extract,
mock_get,
):
mock_get.return_value = (DummyWebhookTrigger(), None, "node_config")
mock_extract.return_value = {"method": "POST"}
response, status = module.handle_webhook_debug("wh-1")
assert status == 409
assert response["error"] == "No active debug listener"
assert response["message"] == (
"The webhook debug URL only works while the Variable Inspector is listening. "
"Use the published webhook URL to execute the workflow in Celery."
)
assert response["execution_url"] == DummyWebhookTrigger.webhook_url
mock_dispatch.assert_called_once()
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
@patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
@patch.object(module.TriggerDebugEventBus, "dispatch", return_value=1)
@patch.object(module.WebhookService, "generate_webhook_response")
def test_debug_success(
self,

View File

@@ -0,0 +1,19 @@
import pytest
from models.enums import CreatorUserRole
def test_creator_user_role_missing_maps_hyphen_to_enum():
# given an alias with hyphen
value = "end-user"
# when converting to enum (invokes StrEnum._missing_ override)
role = CreatorUserRole(value)
# then it should map to END_USER
assert role is CreatorUserRole.END_USER
def test_creator_user_role_missing_raises_for_unknown():
with pytest.raises(ValueError):
CreatorUserRole("unknown")

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,193 @@
import type { InputVar } from '../types'
import type { PromptVariable } from '@/models/debug'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow'
import Features from '../features'
import { InputVarType } from '../types'
import { createStartNode } from './fixtures'
import { renderWorkflowComponent } 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?: Parameters<typeof renderWorkflowComponent>[1]) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow nodes={[startNode]} edges={[]} fitView />
<DelayedFeatures />
</ReactFlowProvider>
</div>,
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

@@ -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

@@ -0,0 +1,108 @@
import type * as React from 'react'
import { act, renderHook } from '@testing-library/react'
import useCheckVerticalScrollbar from '../use-check-vertical-scrollbar'
const resizeObserve = vi.fn()
const resizeDisconnect = vi.fn()
const mutationObserve = vi.fn()
const mutationDisconnect = vi.fn()
let resizeCallback: ResizeObserverCallback | null = null
let mutationCallback: MutationCallback | null = null
class MockResizeObserver implements ResizeObserver {
observe = resizeObserve
unobserve = vi.fn()
disconnect = resizeDisconnect
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback
}
}
class MockMutationObserver implements MutationObserver {
observe = mutationObserve
disconnect = mutationDisconnect
takeRecords = vi.fn(() => [])
constructor(callback: MutationCallback) {
mutationCallback = callback
}
}
const setElementHeights = (element: HTMLElement, scrollHeight: number, clientHeight: number) => {
Object.defineProperty(element, 'scrollHeight', {
configurable: true,
value: scrollHeight,
})
Object.defineProperty(element, 'clientHeight', {
configurable: true,
value: clientHeight,
})
}
describe('useCheckVerticalScrollbar', () => {
beforeEach(() => {
vi.clearAllMocks()
resizeCallback = null
mutationCallback = null
vi.stubGlobal('ResizeObserver', MockResizeObserver)
vi.stubGlobal('MutationObserver', MockMutationObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should return false when the element ref is empty', () => {
const ref = { current: null } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
expect(result.current).toBe(false)
expect(resizeObserve).not.toHaveBeenCalled()
expect(mutationObserve).not.toHaveBeenCalled()
})
it('should detect the initial scrollbar state and react to observer updates', () => {
const element = document.createElement('div')
setElementHeights(element, 200, 100)
const ref = { current: element } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
expect(result.current).toBe(true)
expect(resizeObserve).toHaveBeenCalledWith(element)
expect(mutationObserve).toHaveBeenCalledWith(element, {
childList: true,
subtree: true,
characterData: true,
})
setElementHeights(element, 100, 100)
act(() => {
resizeCallback?.([] as ResizeObserverEntry[], new MockResizeObserver(() => {}))
})
expect(result.current).toBe(false)
setElementHeights(element, 180, 100)
act(() => {
mutationCallback?.([] as MutationRecord[], new MockMutationObserver(() => {}))
})
expect(result.current).toBe(true)
})
it('should disconnect observers on unmount', () => {
const element = document.createElement('div')
setElementHeights(element, 120, 100)
const ref = { current: element } as React.RefObject<HTMLElement | null>
const { unmount } = renderHook(() => useCheckVerticalScrollbar(ref))
unmount()
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,103 @@
import type * as React from 'react'
import { act, renderHook } from '@testing-library/react'
import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll'
const setRect = (element: HTMLElement, top: number, height: number) => {
element.getBoundingClientRect = vi.fn(() => new DOMRect(0, top, 100, height))
}
describe('useStickyScroll', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
const runScroll = (handleScroll: () => void) => {
act(() => {
handleScroll()
vi.advanceTimersByTime(120)
})
}
it('should keep the default state when refs are missing', () => {
const wrapElemRef = { current: null } as React.RefObject<HTMLElement | null>
const nextToStickyELemRef = { current: null } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() =>
useStickyScroll({
wrapElemRef,
nextToStickyELemRef,
}),
)
runScroll(result.current.handleScroll)
expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
})
it('should mark the sticky element as below the wrapper when it is outside the visible area', () => {
const wrapElement = document.createElement('div')
const nextElement = document.createElement('div')
setRect(wrapElement, 100, 200)
setRect(nextElement, 320, 20)
const wrapElemRef = { current: wrapElement } as React.RefObject<HTMLElement | null>
const nextToStickyELemRef = { current: nextElement } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() =>
useStickyScroll({
wrapElemRef,
nextToStickyELemRef,
}),
)
runScroll(result.current.handleScroll)
expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
})
it('should mark the sticky element as showing when it is within the wrapper', () => {
const wrapElement = document.createElement('div')
const nextElement = document.createElement('div')
setRect(wrapElement, 100, 200)
setRect(nextElement, 220, 20)
const wrapElemRef = { current: wrapElement } as React.RefObject<HTMLElement | null>
const nextToStickyELemRef = { current: nextElement } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() =>
useStickyScroll({
wrapElemRef,
nextToStickyELemRef,
}),
)
runScroll(result.current.handleScroll)
expect(result.current.scrollPosition).toBe(ScrollPosition.showing)
})
it('should mark the sticky element as above the wrapper when it has scrolled past the top', () => {
const wrapElement = document.createElement('div')
const nextElement = document.createElement('div')
setRect(wrapElement, 100, 200)
setRect(nextElement, 90, 20)
const wrapElemRef = { current: wrapElement } as React.RefObject<HTMLElement | null>
const nextToStickyELemRef = { current: nextElement } as React.RefObject<HTMLElement | null>
const { result } = renderHook(() =>
useStickyScroll({
wrapElemRef,
nextToStickyELemRef,
}),
)
runScroll(result.current.handleScroll)
expect(result.current.scrollPosition).toBe(ScrollPosition.aboveTheWrap)
})
})

View File

@@ -0,0 +1,108 @@
import type { DataSourceItem } from '../types'
import { transformDataSourceToTool } from '../utils'
const createLocalizedText = (text: string) => ({
en_US: text,
zh_Hans: text,
})
const createDataSourceItem = (overrides: Partial<DataSourceItem> = {}): DataSourceItem => ({
plugin_id: 'plugin-1',
plugin_unique_identifier: 'plugin-1@provider',
provider: 'provider-a',
declaration: {
credentials_schema: [{ name: 'api_key' }],
provider_type: 'hosted',
identity: {
author: 'Dify',
description: createLocalizedText('Datasource provider'),
icon: 'provider-icon',
label: createLocalizedText('Provider A'),
name: 'provider-a',
tags: ['retrieval', 'storage'],
},
datasources: [
{
description: createLocalizedText('Search in documents'),
identity: {
author: 'Dify',
label: createLocalizedText('Document Search'),
name: 'document_search',
provider: 'provider-a',
},
parameters: [{ name: 'query', type: 'string' }],
output_schema: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
},
],
},
is_authorized: true,
...overrides,
})
describe('transformDataSourceToTool', () => {
it('should map datasource provider fields to tool shape', () => {
const dataSourceItem = createDataSourceItem()
const result = transformDataSourceToTool(dataSourceItem)
expect(result).toMatchObject({
id: 'plugin-1',
provider: 'provider-a',
name: 'provider-a',
author: 'Dify',
description: createLocalizedText('Datasource provider'),
icon: 'provider-icon',
label: createLocalizedText('Provider A'),
type: 'hosted',
allow_delete: true,
is_authorized: true,
is_team_authorization: true,
labels: ['retrieval', 'storage'],
plugin_id: 'plugin-1',
plugin_unique_identifier: 'plugin-1@provider',
credentialsSchema: [{ name: 'api_key' }],
meta: { version: '' },
})
expect(result.team_credentials).toEqual({})
expect(result.tools).toEqual([
{
name: 'document_search',
author: 'Dify',
label: createLocalizedText('Document Search'),
description: createLocalizedText('Search in documents'),
parameters: [{ name: 'query', type: 'string' }],
labels: [],
output_schema: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
},
])
})
it('should fallback to empty arrays when tags and credentials schema are missing', () => {
const baseDataSourceItem = createDataSourceItem()
const dataSourceItem = createDataSourceItem({
declaration: {
...baseDataSourceItem.declaration,
credentials_schema: undefined as unknown as DataSourceItem['declaration']['credentials_schema'],
identity: {
...baseDataSourceItem.declaration.identity,
tags: undefined as unknown as DataSourceItem['declaration']['identity']['tags'],
},
},
})
const result = transformDataSourceToTool(dataSourceItem)
expect(result.labels).toEqual([])
expect(result.credentialsSchema).toEqual([])
})
})

View File

@@ -0,0 +1,57 @@
import { fireEvent, render } from '@testing-library/react'
import ViewTypeSelect, { ViewType } from '../view-type-select'
const getViewOptions = (container: HTMLElement) => {
const options = container.firstElementChild?.children
if (!options || options.length !== 2)
throw new Error('Expected two view options')
return [options[0] as HTMLDivElement, options[1] as HTMLDivElement]
}
describe('ViewTypeSelect', () => {
it('should highlight the active view type', () => {
const onChange = vi.fn()
const { container } = render(
<ViewTypeSelect
viewType={ViewType.flat}
onChange={onChange}
/>,
)
const [flatOption, treeOption] = getViewOptions(container)
expect(flatOption).toHaveClass('bg-components-segmented-control-item-active-bg')
expect(treeOption).toHaveClass('cursor-pointer')
})
it('should call onChange when switching to a different view type', () => {
const onChange = vi.fn()
const { container } = render(
<ViewTypeSelect
viewType={ViewType.flat}
onChange={onChange}
/>,
)
const [, treeOption] = getViewOptions(container)
fireEvent.click(treeOption)
expect(onChange).toHaveBeenCalledWith(ViewType.tree)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('should ignore clicks on the current view type', () => {
const onChange = vi.fn()
const { container } = render(
<ViewTypeSelect
viewType={ViewType.tree}
onChange={onChange}
/>,
)
const [, treeOption] = getViewOptions(container)
fireEvent.click(treeOption)
expect(onChange).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
const useCheckVerticalScrollbar = (ref: React.RefObject<HTMLElement>) => {
const useCheckVerticalScrollbar = (ref: React.RefObject<HTMLElement | null>) => {
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false)
useEffect(() => {

View File

@@ -0,0 +1,59 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import ChatVariableButton from '../chat-variable-button'
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
describe('ChatVariableButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('opens the chat variable panel and closes the other workflow panels', () => {
const { store } = renderWorkflowComponent(<ChatVariableButton disabled={false} />, {
initialStoreState: {
showEnvPanel: true,
showGlobalVariablePanel: true,
showDebugAndPreviewPanel: true,
},
})
fireEvent.click(screen.getByRole('button'))
expect(store.getState().showChatVariablePanel).toBe(true)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
})
it('applies the active dark theme styles when the chat variable panel is visible', () => {
mockTheme = 'dark'
renderWorkflowComponent(<ChatVariableButton disabled={false} />, {
initialStoreState: {
showChatVariablePanel: true,
},
})
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
it('stays disabled without mutating panel state', () => {
const { store } = renderWorkflowComponent(<ChatVariableButton disabled />, {
initialStoreState: {
showChatVariablePanel: false,
},
})
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toBeDisabled()
expect(store.getState().showChatVariablePanel).toBe(false)
})
})

View File

@@ -0,0 +1,63 @@
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import EditingTitle from '../editing-title'
const mockFormatTime = vi.fn()
const mockFormatTimeFromNow = vi.fn()
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: mockFormatTime,
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: mockFormatTimeFromNow,
}),
}))
describe('EditingTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormatTime.mockReturnValue('08:00:00')
mockFormatTimeFromNow.mockReturnValue('2 hours ago')
})
it('should render autosave, published time, and syncing status when the draft has metadata', () => {
const { container } = renderWorkflowComponent(<EditingTitle />, {
initialStoreState: {
draftUpdatedAt: 1_710_000_000_000,
publishedAt: 1_710_003_600_000,
isSyncingWorkflowDraft: true,
maximizeCanvas: true,
},
})
expect(mockFormatTime).toHaveBeenCalledWith(1_710_000_000, 'HH:mm:ss')
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1_710_003_600_000)
expect(container.firstChild).toHaveClass('ml-2')
expect(container).toHaveTextContent('workflow.common.autoSaved')
expect(container).toHaveTextContent('08:00:00')
expect(container).toHaveTextContent('workflow.common.published')
expect(container).toHaveTextContent('2 hours ago')
expect(container).toHaveTextContent('workflow.common.syncingData')
})
it('should render unpublished status without autosave metadata when the workflow has not been published', () => {
const { container } = renderWorkflowComponent(<EditingTitle />, {
initialStoreState: {
draftUpdatedAt: 0,
publishedAt: 0,
isSyncingWorkflowDraft: false,
maximizeCanvas: false,
},
})
expect(mockFormatTime).not.toHaveBeenCalled()
expect(mockFormatTimeFromNow).not.toHaveBeenCalled()
expect(container.firstChild).not.toHaveClass('ml-2')
expect(container).toHaveTextContent('workflow.common.unpublished')
expect(container).not.toHaveTextContent('workflow.common.autoSaved')
expect(container).not.toHaveTextContent('workflow.common.syncingData')
})
})

View File

@@ -0,0 +1,68 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import EnvButton from '../env-button'
const mockCloseAllInputFieldPanels = vi.fn()
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
describe('EnvButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('should open the environment panel and close the other panels when clicked', () => {
const { store } = renderWorkflowComponent(<EnvButton disabled={false} />, {
initialStoreState: {
showChatVariablePanel: true,
showGlobalVariablePanel: true,
showDebugAndPreviewPanel: true,
},
})
fireEvent.click(screen.getByRole('button'))
expect(store.getState().showEnvPanel).toBe(true)
expect(store.getState().showChatVariablePanel).toBe(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
})
it('should apply the active dark theme styles when the environment panel is visible', () => {
mockTheme = 'dark'
renderWorkflowComponent(<EnvButton disabled={false} />, {
initialStoreState: {
showEnvPanel: true,
},
})
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
it('should keep the button disabled when the disabled prop is true', () => {
const { store } = renderWorkflowComponent(<EnvButton disabled />, {
initialStoreState: {
showEnvPanel: false,
},
})
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toBeDisabled()
expect(store.getState().showEnvPanel).toBe(false)
expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,68 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import GlobalVariableButton from '../global-variable-button'
const mockCloseAllInputFieldPanels = vi.fn()
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
describe('GlobalVariableButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('should open the global variable panel and close the other panels when clicked', () => {
const { store } = renderWorkflowComponent(<GlobalVariableButton disabled={false} />, {
initialStoreState: {
showEnvPanel: true,
showChatVariablePanel: true,
showDebugAndPreviewPanel: true,
},
})
fireEvent.click(screen.getByRole('button'))
expect(store.getState().showGlobalVariablePanel).toBe(true)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().showChatVariablePanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
})
it('should apply the active dark theme styles when the global variable panel is visible', () => {
mockTheme = 'dark'
renderWorkflowComponent(<GlobalVariableButton disabled={false} />, {
initialStoreState: {
showGlobalVariablePanel: true,
},
})
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
it('should keep the button disabled when the disabled prop is true', () => {
const { store } = renderWorkflowComponent(<GlobalVariableButton disabled />, {
initialStoreState: {
showGlobalVariablePanel: false,
},
})
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toBeDisabled()
expect(store.getState().showGlobalVariablePanel).toBe(false)
expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,109 @@
import type { VersionHistory } from '@/types/workflow'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
import RestoringTitle from '../restoring-title'
const mockFormatTime = vi.fn()
const mockFormatTimeFromNow = vi.fn()
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: mockFormatTime,
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: mockFormatTimeFromNow,
}),
}))
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
},
created_at: 1_700_000_000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1_700_000_100,
updated_by: {
id: 'user-2',
name: 'Bob',
email: 'bob@example.com',
},
tool_published: false,
version: 'v1',
marked_name: 'Release 1',
marked_comment: '',
...overrides,
})
describe('RestoringTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormatTime.mockReturnValue('09:30:00')
mockFormatTimeFromNow.mockReturnValue('3 hours ago')
})
it('should render draft metadata when the current version is a draft', () => {
const currentVersion = createVersion({
version: WorkflowVersion.Draft,
})
const { container } = renderWorkflowComponent(<RestoringTitle />, {
initialStoreState: {
currentVersion,
},
})
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.updated_at * 1000)
expect(mockFormatTime).toHaveBeenCalledWith(currentVersion.created_at, 'HH:mm:ss')
expect(container).toHaveTextContent('workflow.versionHistory.currentDraft')
expect(container).toHaveTextContent('workflow.common.viewOnly')
expect(container).toHaveTextContent('workflow.common.unpublished')
expect(container).toHaveTextContent('3 hours ago 09:30:00')
expect(container).toHaveTextContent('Alice')
})
it('should render published metadata and fallback version name when the marked name is empty', () => {
const currentVersion = createVersion({
marked_name: '',
})
const { container } = renderWorkflowComponent(<RestoringTitle />, {
initialStoreState: {
currentVersion,
},
})
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.created_at * 1000)
expect(container).toHaveTextContent('workflow.versionHistory.defaultName')
expect(container).toHaveTextContent('workflow.common.published')
expect(container).toHaveTextContent('Alice')
})
it('should render an empty creator name when the version creator name is missing', () => {
const currentVersion = createVersion({
created_by: {
id: 'user-1',
name: '',
email: 'alice@example.com',
},
})
const { container } = renderWorkflowComponent(<RestoringTitle />, {
initialStoreState: {
currentVersion,
},
})
expect(container).toHaveTextContent('workflow.common.published')
expect(container).not.toHaveTextContent('Alice')
})
})

View File

@@ -0,0 +1,61 @@
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import RunningTitle from '../running-title'
let mockIsChatMode = false
const mockFormatWorkflowRunIdentifier = vi.fn()
vi.mock('../../hooks', () => ({
useIsChatMode: () => mockIsChatMode,
}))
vi.mock('../../utils', () => ({
formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
}))
describe('RunningTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = false
mockFormatWorkflowRunIdentifier.mockReturnValue(' (14:30:25)')
})
it('should render the test run title in workflow mode', () => {
const { container } = renderWorkflowComponent(<RunningTitle />, {
initialStoreState: {
historyWorkflowData: {
id: 'history-1',
status: 'succeeded',
finished_at: 1_700_000_000,
},
},
})
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1_700_000_000)
expect(container).toHaveTextContent('Test Run (14:30:25)')
expect(container).toHaveTextContent('workflow.common.viewOnly')
})
it('should render the test chat title in chat mode', () => {
mockIsChatMode = true
const { container } = renderWorkflowComponent(<RunningTitle />, {
initialStoreState: {
historyWorkflowData: {
id: 'history-2',
status: 'running',
finished_at: undefined,
},
},
})
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
expect(container).toHaveTextContent('Test Chat (14:30:25)')
})
it('should handle missing workflow history data', () => {
const { container } = renderWorkflowComponent(<RunningTitle />)
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
expect(container).toHaveTextContent('Test Run (14:30:25)')
})
})

View File

@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import ScrollToSelectedNodeButton from '../scroll-to-selected-node-button'
const mockScrollToWorkflowNode = vi.fn()
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
vi.mock('../../utils/node-navigation', () => ({
scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId),
}))
describe('ScrollToSelectedNodeButton', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
})
it('should render nothing when there is no selected node', () => {
rfState.nodes = [
createNode({
id: 'node-1',
data: { selected: false },
}),
]
const { container } = render(<ScrollToSelectedNodeButton />)
expect(container.firstChild).toBeNull()
})
it('should render the action and scroll to the selected node when clicked', () => {
rfState.nodes = [
createNode({
id: 'node-1',
data: { selected: false },
}),
createNode({
id: 'node-2',
data: { selected: true },
}),
]
render(<ScrollToSelectedNodeButton />)
fireEvent.click(screen.getByText('workflow.panel.scrollToSelectedNode'))
expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('node-2')
expect(mockScrollToWorkflowNode).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,118 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import UndoRedo from '../undo-redo'
type TemporalSnapshot = {
pastStates: unknown[]
futureStates: unknown[]
}
const mockUnsubscribe = vi.fn()
const mockTemporalSubscribe = vi.fn()
const mockHandleUndo = vi.fn()
const mockHandleRedo = vi.fn()
let latestTemporalListener: ((state: TemporalSnapshot) => void) | undefined
let mockNodesReadOnly = false
vi.mock('@/app/components/workflow/header/view-workflow-history', () => ({
default: () => <div data-testid="view-workflow-history" />,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
}))
vi.mock('@/app/components/workflow/workflow-history-store', () => ({
useWorkflowHistoryStore: () => ({
store: {
temporal: {
subscribe: mockTemporalSubscribe,
},
},
shortcutsEnabled: true,
setShortcutsEnabled: vi.fn(),
}),
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
vi.mock('@/app/components/workflow/operator/tip-popup', () => ({
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}))
describe('UndoRedo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
latestTemporalListener = undefined
mockTemporalSubscribe.mockImplementation((listener: (state: TemporalSnapshot) => void) => {
latestTemporalListener = listener
return mockUnsubscribe
})
})
it('enables undo and redo when history exists and triggers the callbacks', () => {
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
act(() => {
latestTemporalListener?.({
pastStates: [{}],
futureStates: [{}],
})
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.undo' }))
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.redo' }))
expect(mockHandleUndo).toHaveBeenCalledTimes(1)
expect(mockHandleRedo).toHaveBeenCalledTimes(1)
})
it('keeps the buttons disabled before history is available', () => {
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
fireEvent.click(undoButton)
fireEvent.click(redoButton)
expect(undoButton).toBeDisabled()
expect(redoButton).toBeDisabled()
expect(mockHandleUndo).not.toHaveBeenCalled()
expect(mockHandleRedo).not.toHaveBeenCalled()
})
it('does not trigger callbacks when the canvas is read only', () => {
mockNodesReadOnly = true
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
act(() => {
latestTemporalListener?.({
pastStates: [{}],
futureStates: [{}],
})
})
fireEvent.click(undoButton)
fireEvent.click(redoButton)
expect(undoButton).toBeDisabled()
expect(redoButton).toBeDisabled()
expect(mockHandleUndo).not.toHaveBeenCalled()
expect(mockHandleRedo).not.toHaveBeenCalled()
})
it('unsubscribes from the temporal store on unmount', () => {
const { unmount } = render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
unmount()
expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react'
import VersionHistoryButton from '../version-history-button'
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('../../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils')>()
return {
...actual,
getKeyboardKeyCodeBySystem: () => 'ctrl',
}
})
describe('VersionHistoryButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
it('should call onClick when the button is clicked', () => {
const onClick = vi.fn()
render(<VersionHistoryButton onClick={onClick} />)
fireEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should trigger onClick when the version history shortcut is pressed', () => {
const onClick = vi.fn()
render(<VersionHistoryButton onClick={onClick} />)
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'H',
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
})
Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 })
Object.defineProperty(keyboardEvent, 'which', { value: 72 })
window.dispatchEvent(keyboardEvent)
expect(keyboardEvent.defaultPrevented).toBe(true)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should render the tooltip popup content on hover', async () => {
render(<VersionHistoryButton onClick={vi.fn()} />)
fireEvent.mouseEnter(screen.getByRole('button'))
expect(await screen.findByText('workflow.common.versionHistory')).toBeInTheDocument()
})
it('should apply dark theme styles when the theme is dark', () => {
mockTheme = 'dark'
render(<VersionHistoryButton onClick={vi.fn()} />)
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
})
})

View File

@@ -0,0 +1,276 @@
import type { WorkflowRunHistory, WorkflowRunHistoryResponse } from '@/types/workflow'
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { ControlMode, WorkflowRunningStatus } from '../../types'
import ViewHistory from '../view-history'
const mockUseWorkflowRunHistory = vi.fn()
const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`)
const mockCloseAllInputFieldPanels = vi.fn()
const mockHandleNodesCancelSelected = vi.fn()
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
let mockIsChatMode = false
vi.mock('../../hooks', async () => {
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
return {
...actual,
useIsChatMode: () => mockIsChatMode,
useNodesInteractions: () => ({
handleNodesCancelSelected: mockHandleNodesCancelSelected,
}),
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}
})
vi.mock('@/service/use-workflow', () => ({
useWorkflowRunHistory: (url?: string, enabled?: boolean) => mockUseWorkflowRunHistory(url, enabled),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: mockFormatTimeFromNow,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading" />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
return {
PortalToFollowElem: ({
children,
open,
}: {
children?: React.ReactNode
open: boolean
}) => <PortalContext.Provider value={{ open }}>{children}</PortalContext.Provider>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children?: React.ReactNode
onClick?: () => void
}) => <div data-testid="portal-trigger" onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({
children,
}: {
children?: React.ReactNode
}) => {
const { open } = React.useContext(PortalContext)
return open ? <div data-testid="portal-content">{children}</div> : null
},
}
})
vi.mock('../../utils', async () => {
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
return {
...actual,
formatWorkflowRunIdentifier: (finishedAt?: number, status?: string) => mockFormatWorkflowRunIdentifier(finishedAt, status),
}
})
const createHistoryItem = (overrides: Partial<WorkflowRunHistory> = {}): WorkflowRunHistory => ({
id: 'run-1',
version: 'v1',
graph: {
nodes: [],
edges: [],
},
inputs: {},
status: WorkflowRunningStatus.Succeeded,
outputs: {},
elapsed_time: 1,
total_tokens: 2,
total_steps: 3,
created_at: 100,
finished_at: 120,
created_by_account: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
...overrides,
})
describe('ViewHistory', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = false
mockUseWorkflowRunHistory.mockReturnValue({
data: { data: [] } satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
})
it('defers fetching until the history popup is opened and renders the empty state', () => {
renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
})
expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
expect(mockUseWorkflowRunHistory).toHaveBeenLastCalledWith('/history', true)
expect(screen.getByText('workflow.common.notRunning')).toBeInTheDocument()
expect(screen.getByText('workflow.common.showRunHistory')).toBeInTheDocument()
})
it('renders the icon trigger variant and loading state, and clears log modals on trigger click', () => {
const onClearLogAndMessageModal = vi.fn()
mockUseWorkflowRunHistory.mockReturnValue({
data: { data: [] } satisfies WorkflowRunHistoryResponse,
isLoading: true,
})
renderWorkflowComponent(
<ViewHistory
historyUrl="/history"
onClearLogAndMessageModal={onClearLogAndMessageModal}
/>,
{
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.viewRunHistory' }))
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('renders workflow run history items and updates the workflow store when one is selected', () => {
const handleBackupDraft = vi.fn()
const pausedRun = createHistoryItem({
id: 'run-paused',
status: WorkflowRunningStatus.Paused,
created_at: 101,
finished_at: 0,
})
const failedRun = createHistoryItem({
id: 'run-failed',
status: WorkflowRunningStatus.Failed,
created_at: 102,
finished_at: 130,
})
const succeededRun = createHistoryItem({
id: 'run-succeeded',
status: WorkflowRunningStatus.Succeeded,
created_at: 103,
finished_at: 140,
})
mockUseWorkflowRunHistory.mockReturnValue({
data: {
data: [pausedRun, failedRun, succeededRun],
} satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
const { store } = renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
initialStoreState: {
historyWorkflowData: failedRun,
showInputsPanel: true,
showEnvPanel: true,
controlMode: ControlMode.Pointer,
},
hooksStoreProps: {
handleBackupDraft,
},
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
expect(screen.getByText('Test Run (paused)')).toBeInTheDocument()
expect(screen.getByText('Test Run (failed)')).toBeInTheDocument()
expect(screen.getByText('Test Run (succeeded)')).toBeInTheDocument()
fireEvent.click(screen.getByText('Test Run (succeeded)'))
expect(store.getState().historyWorkflowData).toEqual(succeededRun)
expect(store.getState().showInputsPanel).toBe(false)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().controlMode).toBe(ControlMode.Hand)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
expect(handleBackupDraft).toHaveBeenCalledTimes(1)
expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
it('renders chat history labels without workflow status icons in chat mode', () => {
mockIsChatMode = true
const chatRun = createHistoryItem({
id: 'chat-run',
status: WorkflowRunningStatus.Failed,
})
mockUseWorkflowRunHistory.mockReturnValue({
data: {
data: [chatRun],
} satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
expect(screen.getByText('Test Chat (failed)')).toBeInTheDocument()
})
it('closes the popup from the close button and clears log modals', () => {
const onClearLogAndMessageModal = vi.fn()
mockUseWorkflowRunHistory.mockReturnValue({
data: { data: [] } satisfies WorkflowRunHistoryResponse,
isLoading: false,
})
renderWorkflowComponent(
<ViewHistory
historyUrl="/history"
withText
onClearLogAndMessageModal={onClearLogAndMessageModal}
/>,
{
hooksStoreProps: {
handleBackupDraft: vi.fn(),
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { CommonNodeType } from '../types'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import { cn } from '@/utils/classnames'
@@ -11,21 +10,15 @@ const ScrollToSelectedNodeButton: FC = () => {
const nodes = useNodes<CommonNodeType>()
const selectedNode = nodes.find(node => node.data.selected)
const handleScrollToSelectedNode = useCallback(() => {
if (!selectedNode)
return
scrollToWorkflowNode(selectedNode.id)
}, [selectedNode])
if (!selectedNode)
return null
return (
<div
className={cn(
'system-xs-medium flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 hover:text-text-accent',
'flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 system-xs-medium hover:text-text-accent',
)}
onClick={handleScrollToSelectedNode}
onClick={() => scrollToWorkflowNode(selectedNode.id)}
>
{t('panel.scrollToSelectedNode', { ns: 'workflow' })}
</div>

View File

@@ -1,8 +1,4 @@
import type { FC } from 'react'
import {
RiArrowGoBackLine,
RiArrowGoForwardFill,
} from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
@@ -33,28 +29,34 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
return (
<div className="flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]">
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcuts={['ctrl', 'z']}>
<div
<button
type="button"
aria-label={t('common.undo', { ns: 'workflow' })!}
data-tooltip-id="workflow.undo"
disabled={nodesReadOnly || buttonsDisabled.undo}
className={
cn('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.undo)
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
onClick={handleUndo}
>
<RiArrowGoBackLine className="h-4 w-4" />
</div>
<span className="i-ri-arrow-go-back-line h-4 w-4" />
</button>
</TipPopup>
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcuts={['ctrl', 'y']}>
<div
<button
type="button"
aria-label={t('common.redo', { ns: 'workflow' })!}
data-tooltip-id="workflow.redo"
disabled={nodesReadOnly || buttonsDisabled.redo}
className={
cn('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.redo)
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
onClick={handleRedo}
>
<RiArrowGoForwardFill className="h-4 w-4" />
</div>
<span className="i-ri-arrow-go-forward-fill h-4 w-4" />
</button>
</TipPopup>
<Divider type="vertical" className="mx-0.5 h-3.5" />
<ViewWorkflowHistory />

View File

@@ -73,15 +73,18 @@ const ViewHistory = ({
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
{
withText && (
<div className={cn(
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
open && 'bg-components-button-secondary-bg-hover',
)}
<button
type="button"
aria-label={t('common.showRunHistory', { ns: 'workflow' })}
className={cn(
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
open && 'bg-components-button-secondary-bg-hover',
)}
>
<span className="i-custom-vender-line-time-clock-play mr-1 h-4 w-4" />
{t('common.showRunHistory', { ns: 'workflow' })}
</div>
</button>
)
}
{
@@ -89,14 +92,16 @@ const ViewHistory = ({
<Tooltip
popupContent={t('common.viewRunHistory', { ns: 'workflow' })}
>
<div
<button
type="button"
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
onClick={() => {
onClearLogAndMessageModal?.()
}}
>
<span className={cn('i-custom-vender-line-time-clock-play', 'h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
</div>
</button>
</Tooltip>
)
}
@@ -110,7 +115,9 @@ const ViewHistory = ({
>
<div className="sticky top-0 flex items-center justify-between bg-components-panel-bg px-4 pt-3 text-base font-semibold text-text-primary">
<div className="grow">{t('common.runHistory', { ns: 'workflow' })}</div>
<div
<button
type="button"
aria-label={t('operation.close', { ns: 'common' })}
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
onClick={() => {
onClearLogAndMessageModal?.()
@@ -118,7 +125,7 @@ const ViewHistory = ({
}}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
</button>
</div>
{
isLoading && (

View File

@@ -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 }) => (
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
),
}))
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
Stop: ({ className }: { className?: string }) => <div data-testid="stop-icon" className={className} />,
}))
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<typeof import('../../../hooks')>('../../../hooks')
return {
...actual,
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}),
}))
}
})
vi.mock('../../../utils', () => ({
canRunBySingle: mockCanRunBySingle,
}))
vi.mock('../../../utils', async () => {
const actual = await vi.importActual<typeof import('../../../utils')>('../../../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 (
<NodeControl
id={id}
data={data}
pluginInstallLocked={mockPluginInstallLocked}
/>
)
}
const makeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
type: BlockEnum.Code,
title: 'Node',
@@ -73,65 +65,71 @@ const makeData = (overrides: Partial<CommonNodeType> = {}): 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(
<NodeControl
id="node-1"
data={makeData()}
/>,
)
// 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(
<NodeControlHarness id="node-1" data={makeData()} />,
)
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(
<NodeControlHarness
id="node-2"
data={makeData({
selected: true,
_singleRunningStatus: NodeRunningStatus.Running,
})}
/>,
)
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(
<NodeControl
id="node-2"
pluginInstallLocked
data={makeData({
selected: true,
_singleRunningStatus: NodeRunningStatus.Running,
isInIteration: true,
})}
/>,
)
// 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(
<NodeControlHarness
id="node-3"
data={makeData({
selected: true,
})}
/>,
)
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(
<NodeControlHarness
id="node-4"
data={makeData()}
/>,
)
it('should hide the run control when single-node execution is not supported', () => {
mockCanRunBySingle.mockReturnValue(false)
render(
<NodeControl
id="node-3"
data={makeData()}
/>,
)
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()
})
})
})

View File

@@ -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<NodeControlProps> = ({
>
{
canRunBySingle(data.type, isChildNode) && (
<div
<button
type="button"
aria-label={isSingleRunning ? t('debug.variableInspect.trigger.stop', { ns: 'workflow' }) : t('panel.runThisStep', { ns: 'workflow' })}
className={`flex h-5 w-5 items-center justify-center rounded-md ${isSingleRunning && 'cursor-pointer hover:bg-state-base-hover'}`}
onClick={() => {
const action = isSingleRunning ? 'stop' : 'run'
@@ -76,11 +75,11 @@ const NodeControl: FC<NodeControlProps> = ({
popupContent={t('panel.runThisStep', { ns: 'workflow' })}
asChild={false}
>
<RiPlayLargeLine className="h-3 w-3" />
<span className="i-ri-play-large-line h-3 w-3" />
</Tooltip>
)
}
</div>
</button>
)
}
<PanelOperator

View File

@@ -1,90 +1,68 @@
import type { WebhookTriggerNodeType } from '../types'
import type { NodePanelProps } from '@/app/components/workflow/types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import Panel from '../panel'
const {
mockHandleStatusCodeChange,
mockGenerateWebhookUrl,
mockHandleMethodChange,
mockHandleContentTypeChange,
mockHandleHeadersChange,
mockHandleParamsChange,
mockHandleBodyChange,
mockHandleResponseBodyChange,
} = vi.hoisted(() => ({
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: () => <div data-testid="input-with-copy" />,
}))
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: () => <div data-testid="simple-select" />,
}))
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 }) => (
<section>
<div>{title}</div>
{children}
</section>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: () => <div data-testid="output-vars" />,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div data-testid="split" />,
}))
vi.mock('../components/header-table', () => ({
default: () => <div data-testid="header-table" />,
}))
vi.mock('../components/parameter-table', () => ({
default: () => <div data-testid="parameter-table" />,
}))
vi.mock('../components/paragraph-input', () => ({
default: () => <div data-testid="paragraph-input" />,
}))
vi.mock('../utils/render-output-vars', () => ({
OutputVariablesContent: () => <div data-testid="output-variables-content" />,
}))
const getStatusCodeInput = () => {
return screen.getAllByDisplayValue('200')
.find(element => element.getAttribute('aria-hidden') !== 'true') as HTMLInputElement
}
describe('WebhookTriggerPanel', () => {
const panelProps: NodePanelProps<WebhookTriggerNodeType> = {
@@ -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(<Panel {...panelProps} />)
describe('Rendering', () => {
it('should render the real panel fields without generating a new webhook url when one already exists', () => {
render(<Panel {...panelProps} />)
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(<Panel {...panelProps} />)
await waitFor(() => {
expect(mockGenerateWebhookUrl).toHaveBeenCalledTimes(1)
})
})
})
it('should ignore clear changes until the value is committed', () => {
render(<Panel {...panelProps} />)
describe('Status Code Input', () => {
it('should update the status code when users enter a parseable value', () => {
render(<Panel {...panelProps} />)
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(<Panel {...panelProps} />)
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
const input = getStatusCodeInput()
fireEvent.change(input, { target: { value: '' } })
expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
fireEvent.blur(input)
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
})
})
})

View File

@@ -0,0 +1,144 @@
import type { Node as ReactFlowNode } from 'reactflow'
import type { NoteNodeType } from '../types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { CUSTOM_NOTE_NODE } from '../constants'
import NoteNode from '../index'
import { NoteTheme } from '../types'
const {
mockHandleEditorChange,
mockHandleNodeDataUpdateWithSyncDraft,
mockHandleNodeDelete,
mockHandleNodesCopy,
mockHandleNodesDuplicate,
mockHandleShowAuthorChange,
mockHandleThemeChange,
mockSetShortcutsEnabled,
} = vi.hoisted(() => ({
mockHandleEditorChange: vi.fn(),
mockHandleNodeDataUpdateWithSyncDraft: vi.fn(),
mockHandleNodeDelete: vi.fn(),
mockHandleNodesCopy: vi.fn(),
mockHandleNodesDuplicate: vi.fn(),
mockHandleShowAuthorChange: vi.fn(),
mockHandleThemeChange: vi.fn(),
mockSetShortcutsEnabled: vi.fn(),
}))
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
useNodeDataUpdate: () => ({
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
}),
useNodesInteractions: () => ({
handleNodesCopy: mockHandleNodesCopy,
handleNodesDuplicate: mockHandleNodesDuplicate,
handleNodeDelete: mockHandleNodeDelete,
}),
}
})
vi.mock('../hooks', () => ({
useNote: () => ({
handleThemeChange: mockHandleThemeChange,
handleEditorChange: mockHandleEditorChange,
handleShowAuthorChange: mockHandleShowAuthorChange,
}),
}))
vi.mock('../../workflow-history-store', () => ({
useWorkflowHistoryStore: () => ({
setShortcutsEnabled: mockSetShortcutsEnabled,
}),
}))
const createNoteData = (overrides: Partial<NoteNodeType> = {}): NoteNodeType => ({
title: '',
desc: '',
type: '' as unknown as NoteNodeType['type'],
text: '',
theme: NoteTheme.blue,
author: 'Alice',
showAuthor: true,
width: 240,
height: 88,
selected: true,
...overrides,
})
const renderNoteNode = (dataOverrides: Partial<NoteNodeType> = {}) => {
const nodeData = createNoteData(dataOverrides)
const nodes: Array<ReactFlowNode<NoteNodeType>> = [
{
id: 'note-1',
type: CUSTOM_NOTE_NODE,
position: { x: 0, y: 0 },
data: nodeData,
selected: !!nodeData.selected,
},
]
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow
fitView
nodes={nodes}
edges={[]}
nodeTypes={{
[CUSTOM_NOTE_NODE]: NoteNode,
}}
/>
</ReactFlowProvider>
</div>,
{
initialStoreState: {
controlPromptEditorRerenderKey: 0,
},
},
)
}
describe('NoteNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the toolbar and author for a selected persistent note', async () => {
renderNoteNode()
expect(screen.getByText('Alice')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument()
})
})
it('should hide the toolbar for temporary notes', () => {
renderNoteNode({
_isTempNode: true,
showAuthor: false,
})
expect(screen.queryByText('workflow.nodes.note.editor.small')).not.toBeInTheDocument()
})
it('should clear the selected state when clicking outside the note', async () => {
renderNoteNode()
fireEvent.click(document.body)
await waitFor(() => {
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
id: 'note-1',
data: {
selected: false,
},
})
})
})
})

View File

@@ -0,0 +1,138 @@
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { render, screen, waitFor } from '@testing-library/react'
import { $getRoot } from 'lexical'
import { useEffect } from 'react'
import { NoteEditorContextProvider } from '../context'
import { useStore } from '../store'
const emptyValue = JSON.stringify({ root: { children: [] } })
const populatedValue = JSON.stringify({
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'hello',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
textFormat: 0,
textStyle: '',
type: 'paragraph',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
})
const readEditorText = (editor: LexicalEditor) => {
let text = ''
editor.getEditorState().read(() => {
text = $getRoot().getTextContent()
})
return text
}
const ContextProbe = ({
onReady,
}: {
onReady?: (editor: LexicalEditor) => void
}) => {
const [editor] = useLexicalComposerContext()
const selectedIsBold = useStore(state => state.selectedIsBold)
useEffect(() => {
onReady?.(editor)
}, [editor, onReady])
return <div>{selectedIsBold ? 'bold' : 'not-bold'}</div>
}
describe('NoteEditorContextProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Provider should expose the store and render the wrapped editor tree.
describe('Rendering', () => {
it('should render children with the note editor store defaults', async () => {
let editor: LexicalEditor | null = null
render(
<NoteEditorContextProvider value={emptyValue}>
<ContextProbe onReady={instance => (editor = instance)} />
</NoteEditorContextProvider>,
)
expect(screen.getByText('not-bold')).toBeInTheDocument()
await waitFor(() => {
expect(editor).not.toBeNull()
})
expect(editor!.isEditable()).toBe(true)
expect(readEditorText(editor!)).toBe('')
})
})
// Invalid or empty editor state should fall back to an empty lexical state.
describe('Editor State Initialization', () => {
it.each([
{
name: 'value is malformed json',
value: '{invalid',
},
{
name: 'root has no children',
value: emptyValue,
},
])('should use an empty editor state when $name', async ({ value }) => {
let editor: LexicalEditor | null = null
render(
<NoteEditorContextProvider value={value}>
<ContextProbe onReady={instance => (editor = instance)} />
</NoteEditorContextProvider>,
)
await waitFor(() => {
expect(editor).not.toBeNull()
})
expect(readEditorText(editor!)).toBe('')
})
it('should restore lexical content and forward editable prop', async () => {
let editor: LexicalEditor | null = null
render(
<NoteEditorContextProvider value={populatedValue} editable={false}>
<ContextProbe onReady={instance => (editor = instance)} />
</NoteEditorContextProvider>,
)
await waitFor(() => {
expect(editor).not.toBeNull()
expect(readEditorText(editor!)).toBe('hello')
})
expect(editor!.isEditable()).toBe(false)
})
})
})

View File

@@ -0,0 +1,120 @@
import type { EditorState, LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'
import { useEffect } from 'react'
import { NoteEditorContextProvider } from '../context'
import Editor from '../editor'
const emptyValue = JSON.stringify({ root: { children: [] } })
const EditorProbe = ({
onReady,
}: {
onReady?: (editor: LexicalEditor) => void
}) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
onReady?.(editor)
}, [editor, onReady])
return null
}
const renderEditor = (
props: Partial<React.ComponentProps<typeof Editor>> = {},
onEditorReady?: (editor: LexicalEditor) => void,
) => {
return render(
<NoteEditorContextProvider value={emptyValue}>
<>
<Editor
containerElement={document.createElement('div')}
{...props}
/>
<EditorProbe onReady={onEditorReady} />
</>
</NoteEditorContextProvider>,
)
}
describe('Editor', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Editor should render the lexical surface with the provided placeholder.
describe('Rendering', () => {
it('should render the placeholder text and content editable surface', () => {
renderEditor({ placeholder: 'Type note' })
expect(screen.getByText('Type note')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
// Focus and blur should toggle workflow shortcuts while editing content.
describe('Focus Management', () => {
it('should disable shortcuts on focus and re-enable them on blur', () => {
const setShortcutsEnabled = vi.fn()
renderEditor({ setShortcutsEnabled })
const contentEditable = screen.getByRole('textbox')
fireEvent.focus(contentEditable)
fireEvent.blur(contentEditable)
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(1, false)
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(2, true)
})
})
// Lexical change events should be forwarded to the external onChange callback.
describe('Change Handling', () => {
it('should pass editor updates through onChange', async () => {
const changes: string[] = []
let editor: LexicalEditor | null = null
const handleChange = (editorState: EditorState) => {
editorState.read(() => {
changes.push($getRoot().getTextContent())
})
}
renderEditor({ onChange: handleChange }, instance => (editor = instance))
await waitFor(() => {
expect(editor).not.toBeNull()
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
act(() => {
editor!.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
paragraph.append($createTextNode('hello'))
root.append(paragraph)
}, { discrete: true })
})
act(() => {
editor!.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
paragraph.append($createTextNode('hello world'))
root.append(paragraph)
}, { discrete: true })
})
await waitFor(() => {
expect(changes).toContain('hello world')
})
})
})
})

View File

@@ -0,0 +1,24 @@
import { render } from '@testing-library/react'
import { NoteEditorContextProvider } from '../../../context'
import FormatDetectorPlugin from '../index'
const emptyValue = JSON.stringify({ root: { children: [] } })
describe('FormatDetectorPlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The plugin should register its observers without rendering extra UI.
describe('Rendering', () => {
it('should mount inside the real note editor context without visible output', () => {
const { container } = render(
<NoteEditorContextProvider value={emptyValue}>
<FormatDetectorPlugin />
</NoteEditorContextProvider>,
)
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@@ -0,0 +1,71 @@
import type { createNoteEditorStore } from '../../../store'
import { act, render, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { NoteEditorContextProvider } from '../../../context'
import { useNoteEditorStore } from '../../../store'
import LinkEditorPlugin from '../index'
type NoteEditorStore = ReturnType<typeof createNoteEditorStore>
const emptyValue = JSON.stringify({ root: { children: [] } })
const StoreProbe = ({
onReady,
}: {
onReady?: (store: NoteEditorStore) => void
}) => {
const store = useNoteEditorStore()
useEffect(() => {
onReady?.(store)
}, [onReady, store])
return null
}
describe('LinkEditorPlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Without an anchor element the plugin should stay hidden.
describe('Visibility', () => {
it('should render nothing when no link anchor is selected', () => {
const { container } = render(
<NoteEditorContextProvider value={emptyValue}>
<LinkEditorPlugin containerElement={null} />
</NoteEditorContextProvider>,
)
expect(container).toBeEmptyDOMElement()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should render the link editor when the store has an anchor element', async () => {
let store: NoteEditorStore | null = null
render(
<NoteEditorContextProvider value={emptyValue}>
<StoreProbe onReady={instance => (store = instance)} />
<LinkEditorPlugin containerElement={document.createElement('div')} />
</NoteEditorContextProvider>,
)
await waitFor(() => {
expect(store).not.toBeNull()
})
act(() => {
store!.setState({
linkAnchorElement: document.createElement('a'),
linkOperatorShow: false,
selectedLinkUrl: 'https://example.com',
})
})
await waitFor(() => {
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
})
})
})
})

View File

@@ -0,0 +1,32 @@
import { fireEvent, render, waitFor } from '@testing-library/react'
import { NoteTheme } from '../../../types'
import ColorPicker, { COLOR_LIST } from '../color-picker'
describe('NoteEditor ColorPicker', () => {
it('should open the palette and apply the selected theme', async () => {
const onThemeChange = vi.fn()
const { container } = render(
<ColorPicker theme={NoteTheme.blue} onThemeChange={onThemeChange} />,
)
const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
fireEvent.click(trigger)
const popup = document.body.querySelector('[role="tooltip"]')
expect(popup).toBeInTheDocument()
const options = popup?.querySelectorAll('.group.relative')
expect(options).toHaveLength(COLOR_LIST.length)
fireEvent.click(options?.[COLOR_LIST.length - 1] as Element)
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
await waitFor(() => {
expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,62 @@
import { fireEvent, render } from '@testing-library/react'
import Command from '../command'
const { mockHandleCommand } = vi.hoisted(() => ({
mockHandleCommand: vi.fn(),
}))
let mockSelectedState = {
selectedIsBold: false,
selectedIsItalic: false,
selectedIsStrikeThrough: false,
selectedIsLink: false,
selectedIsBullet: false,
}
vi.mock('../../store', () => ({
useStore: (selector: (state: typeof mockSelectedState) => unknown) => selector(mockSelectedState),
}))
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>()
return {
...actual,
useCommand: () => ({
handleCommand: mockHandleCommand,
}),
}
})
describe('NoteEditor Command', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSelectedState = {
selectedIsBold: false,
selectedIsItalic: false,
selectedIsStrikeThrough: false,
selectedIsLink: false,
selectedIsBullet: false,
}
})
it('should highlight the active command and dispatch it on click', () => {
mockSelectedState.selectedIsBold = true
const { container } = render(<Command type="bold" />)
const trigger = container.querySelector('.cursor-pointer') as HTMLElement
expect(trigger).toHaveClass('bg-state-accent-active')
fireEvent.click(trigger)
expect(mockHandleCommand).toHaveBeenCalledWith('bold')
})
it('should keep inactive commands unhighlighted', () => {
const { container } = render(<Command type="link" />)
const trigger = container.querySelector('.cursor-pointer') as HTMLElement
expect(trigger).not.toHaveClass('bg-state-accent-active')
})
})

View File

@@ -0,0 +1,55 @@
import { fireEvent, render, screen } from '@testing-library/react'
import FontSizeSelector from '../font-size-selector'
const {
mockHandleFontSize,
mockHandleOpenFontSizeSelector,
} = vi.hoisted(() => ({
mockHandleFontSize: vi.fn(),
mockHandleOpenFontSizeSelector: vi.fn(),
}))
let mockFontSizeSelectorShow = false
let mockFontSize = '12px'
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>()
return {
...actual,
useFontSize: () => ({
fontSize: mockFontSize,
fontSizeSelectorShow: mockFontSizeSelectorShow,
handleFontSize: mockHandleFontSize,
handleOpenFontSizeSelector: mockHandleOpenFontSizeSelector,
}),
}
})
describe('NoteEditor FontSizeSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFontSizeSelectorShow = false
mockFontSize = '12px'
})
it('should show the current font size label and request opening when clicked', () => {
render(<FontSizeSelector />)
fireEvent.click(screen.getByText('workflow.nodes.note.editor.small'))
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(true)
})
it('should select a new font size and close the popup', () => {
mockFontSizeSelectorShow = true
mockFontSize = '14px'
render(<FontSizeSelector />)
fireEvent.click(screen.getByText('workflow.nodes.note.editor.large'))
expect(screen.getAllByText('workflow.nodes.note.editor.medium').length).toBeGreaterThan(0)
expect(mockHandleFontSize).toHaveBeenCalledWith('16px')
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,101 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NoteTheme } from '../../../types'
import Toolbar from '../index'
const {
mockHandleCommand,
mockHandleFontSize,
mockHandleOpenFontSizeSelector,
} = vi.hoisted(() => ({
mockHandleCommand: vi.fn(),
mockHandleFontSize: vi.fn(),
mockHandleOpenFontSizeSelector: vi.fn(),
}))
let mockFontSizeSelectorShow = false
let mockFontSize = '14px'
let mockSelectedState = {
selectedIsBold: false,
selectedIsItalic: false,
selectedIsStrikeThrough: false,
selectedIsLink: false,
selectedIsBullet: false,
}
vi.mock('../../store', () => ({
useStore: (selector: (state: typeof mockSelectedState) => unknown) => selector(mockSelectedState),
}))
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>()
return {
...actual,
useCommand: () => ({
handleCommand: mockHandleCommand,
}),
useFontSize: () => ({
fontSize: mockFontSize,
fontSizeSelectorShow: mockFontSizeSelectorShow,
handleFontSize: mockHandleFontSize,
handleOpenFontSizeSelector: mockHandleOpenFontSizeSelector,
}),
}
})
describe('NoteEditor Toolbar', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFontSizeSelectorShow = false
mockFontSize = '14px'
mockSelectedState = {
selectedIsBold: false,
selectedIsItalic: false,
selectedIsStrikeThrough: false,
selectedIsLink: false,
selectedIsBullet: false,
}
})
it('should compose the toolbar controls and forward callbacks from color and operator actions', async () => {
const onCopy = vi.fn()
const onDelete = vi.fn()
const onDuplicate = vi.fn()
const onShowAuthorChange = vi.fn()
const onThemeChange = vi.fn()
const { container } = render(
<Toolbar
theme={NoteTheme.blue}
onThemeChange={onThemeChange}
onCopy={onCopy}
onDuplicate={onDuplicate}
onDelete={onDelete}
showAuthor={false}
onShowAuthorChange={onShowAuthorChange}
/>,
)
expect(screen.getByText('workflow.nodes.note.editor.medium')).toBeInTheDocument()
const triggers = container.querySelectorAll('[data-state="closed"]')
fireEvent.click(triggers[0] as HTMLElement)
const colorOptions = document.body.querySelectorAll('[role="tooltip"] .group.relative')
fireEvent.click(colorOptions[colorOptions.length - 1] as Element)
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
fireEvent.click(container.querySelectorAll('[data-state="closed"]')[container.querySelectorAll('[data-state="closed"]').length - 1] as HTMLElement)
fireEvent.click(screen.getByText('workflow.common.copy'))
expect(onCopy).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument()
})
expect(onDelete).not.toHaveBeenCalled()
expect(onDuplicate).not.toHaveBeenCalled()
expect(onShowAuthorChange).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Operator from '../operator'
const renderOperator = (showAuthor = false) => {
const onCopy = vi.fn()
const onDuplicate = vi.fn()
const onDelete = vi.fn()
const onShowAuthorChange = vi.fn()
const renderResult = render(
<Operator
onCopy={onCopy}
onDuplicate={onDuplicate}
onDelete={onDelete}
showAuthor={showAuthor}
onShowAuthorChange={onShowAuthorChange}
/>,
)
return {
...renderResult,
onCopy,
onDelete,
onDuplicate,
onShowAuthorChange,
}
}
describe('NoteEditor Toolbar Operator', () => {
it('should trigger copy, duplicate, and delete from the opened menu', () => {
const {
container,
onCopy,
onDelete,
onDuplicate,
} = renderOperator()
const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
fireEvent.click(trigger)
fireEvent.click(screen.getByText('workflow.common.copy'))
expect(onCopy).toHaveBeenCalledTimes(1)
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
fireEvent.click(screen.getByText('workflow.common.duplicate'))
expect(onDuplicate).toHaveBeenCalledTimes(1)
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
fireEvent.click(screen.getByText('common.operation.delete'))
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should forward the switch state through onShowAuthorChange', () => {
const {
container,
onShowAuthorChange,
} = renderOperator(true)
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
fireEvent.click(screen.getByRole('switch'))
expect(onShowAuthorChange).toHaveBeenCalledWith(false)
})
})

View File

@@ -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<string, unknown>) => 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<string, unknown> }) => ({
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 (
<div data-testid="block-selector">
{props.trigger(props.open)}
</div>
)
},
}))
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(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow nodes={nodes} edges={[]} fitView />
<AddBlock />
</ReactFlowProvider>
</div>,
)
}
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(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow nodes={[]} edges={[]} fitView />
<AddBlock />
</ReactFlowProvider>
</div>,
)
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,
},
},
})
})
})
})

View File

@@ -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: () => <div data-testid="add-block" />,
}))
vi.mock('../more-actions', () => ({
default: () => <div data-testid="more-actions" />,
}))
vi.mock('../tip-popup', () => ({
default: ({
children,
title,
}: {
children?: ReactNode
title?: string
}) => <div data-testid={title}>{children}</div>,
}))
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(<Control />)
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(<Control />)
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(<Control />)
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(<Control />)
fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement)
expect(mockHandleAddNote).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,145 @@
import type { Node as ReactFlowNode } from 'reactflow'
import type { CommonNodeType } from '../../types'
import { act, screen } from '@testing-library/react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import Operator from '../index'
const mockEmit = vi.fn()
const mockDeleteAllInspectorVars = vi.fn()
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: vi.fn(),
}),
useWorkflowReadOnly: () => ({
workflowReadOnly: false,
getWorkflowReadOnly: () => false,
}),
}
})
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
default: () => ({
conversationVars: [],
systemVars: [],
nodesWithInspectVars: [],
deleteAllInspectorVars: mockDeleteAllInspectorVars,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
const createNode = (): ReactFlowNode<CommonNodeType> => ({
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Code,
title: 'Code',
desc: '',
},
})
const originalResizeObserver = globalThis.ResizeObserver
let resizeObserverCallback: ResizeObserverCallback | undefined
const observeSpy = vi.fn()
const disconnectSpy = vi.fn()
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallback = callback
}
observe(...args: Parameters<ResizeObserver['observe']>) {
observeSpy(...args)
}
unobserve() {
return undefined
}
disconnect() {
disconnectSpy()
}
}
const renderOperator = (initialStoreState: Record<string, unknown> = {}) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow fitView nodes={[createNode()]} edges={[]} />
<Operator handleUndo={vi.fn()} handleRedo={vi.fn()} />
</ReactFlowProvider>
</div>,
{
initialStoreState,
historyStore: {
nodes: [],
edges: [],
},
},
)
}
describe('Operator', () => {
beforeEach(() => {
vi.clearAllMocks()
resizeObserverCallback = undefined
vi.stubGlobal('ResizeObserver', MockResizeObserver as unknown as typeof ResizeObserver)
})
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver
})
it('should keep the operator width on the 400px floor when the available width is smaller', () => {
const { container } = renderOperator({
workflowCanvasWidth: 620,
rightPanelWidth: 350,
})
expect(screen.getByText('workflow.debug.variableInspect.trigger.normal')).toBeInTheDocument()
expect(container.querySelector('div[style*="width: 400px"]')).toBeInTheDocument()
})
it('should fall back to auto width before layout metrics are ready', () => {
const { container } = renderOperator()
expect(container.querySelector('div[style*="width: auto"]')).toBeInTheDocument()
})
it('should sync the observed panel size back into the workflow store and disconnect on unmount', () => {
const { store, unmount } = renderOperator({
workflowCanvasWidth: 900,
rightPanelWidth: 260,
})
expect(observeSpy).toHaveBeenCalled()
act(() => {
resizeObserverCallback?.([
{
borderBoxSize: [{ inlineSize: 512, blockSize: 188 }],
} as unknown as ResizeObserverEntry,
], {} as ResizeObserver)
})
expect(store.getState().bottomPanelWidth).toBe(512)
expect(store.getState().bottomPanelHeight).toBe(188)
unmount()
expect(disconnectSpy).toHaveBeenCalled()
})
})

View File

@@ -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<HooksStoreShape> = {},
): Partial<HooksStoreShape> => ({
handleRun: vi.fn(),
configsMap: {
flowId: 'flow-1',
flowType: FlowType.appFlow,
fileSettings: fileSettingsWithImage,
},
...overrides,
})
const renderInputsPanel = (
startNode: ReturnType<typeof createStartNode>,
options?: Parameters<typeof renderWorkflowComponent>[1],
) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow nodes={[startNode]} edges={[]} fitView />
<InputsPanel onRun={vi.fn()} />
</ReactFlowProvider>
</div>,
options,
)
}
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(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow
nodes={[
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
default: 'default question',
},
],
},
}),
]}
edges={[]}
fitView
/>
<InputsPanel onRun={onRun} />
</ReactFlowProvider>
</div>,
{
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(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow
nodes={[
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
},
{
type: InputVarType.checkbox,
variable: 'confirmed',
label: 'Confirmed',
required: false,
},
],
},
}),
]}
edges={[]}
fitView
/>
<InputsPanel onRun={onRun} />
</ReactFlowProvider>
</div>,
{
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()
})
})
})

View File

@@ -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 (
<div
data-run-detail-url={runDetailUrl}
data-testid="run"
data-tracing-list-url={tracingListUrl}
/>
)
},
}))
vi.mock('@/app/components/workflow/utils', () => ({
formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
}))
const createRunDetail = (overrides: Partial<WorkflowRunDetailResponse> = {}): 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(<Record />, {
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(<Record />, {
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(<Record />, {
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,
})
})
})

View File

@@ -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(<Meta status="running" />)
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(<Meta status={status} />)
expect(screen.getByText(label)).toBeInTheDocument()
})
it('renders explicit metadata values and hides steps when requested', () => {
render(
<Meta
status="succeeded"
executor="Alice"
startTime={1700000000000}
time={1.2349}
tokens={42}
steps={3}
showSteps={false}
/>,
)
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(<Meta status="failed" />)
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()
})
})

View File

@@ -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: () => <div data-testid="loading-anim" />,
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileList: ({ files }: { files: FileEntity[] }) => (
<div data-testid="file-list">{files.map(file => file.name).join(', ')}</div>
),
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
}))
vi.mock('@/app/components/workflow/run/status-container', () => ({
default: ({ status, children }: { status: string, children?: React.ReactNode }) => (
<div data-status={status} data-testid="status-container">{children}</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({
language,
value,
height,
}: {
language: string
value: string
height?: number
}) => (
<div data-height={height} data-language={language} data-testid="code-editor" data-value={value}>
{value}
</div>
),
}))
const createFileOutput = (overrides: Partial<FileOutput> = {}): 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(<OutputPanel isRunning />)
expect(screen.getByTestId('loading-anim')).toBeInTheDocument()
})
it('renders the failed status container when there is an error', () => {
render(<OutputPanel error="Execution failed" />)
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(<OutputPanel />)
expect(screen.getByTestId('markdown')).toHaveTextContent('No Output')
})
it('renders a plain text output as markdown', () => {
render(<OutputPanel outputs={{ answer: 'Hello Dify' }} />)
expect(screen.getByTestId('markdown')).toHaveTextContent('Hello Dify')
})
it('renders array text outputs as joined markdown content', () => {
render(<OutputPanel outputs={{ answer: ['Line 1', 'Line 2'] }} />)
expect(screen.getByTestId('markdown')).toHaveTextContent(/Line 1\s+Line 2/)
})
it('renders a file list for a single file output', () => {
render(<OutputPanel outputs={{ attachment: createFileOutput() }} />)
expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf')
})
it('renders a file list for an array of file outputs', () => {
render(
<OutputPanel
outputs={{
attachments: [
createFileOutput(),
createFileOutput({
related_id: 'file-2',
filename: 'summary.md',
extension: 'md',
mime_type: 'text/markdown',
type: 'custom',
upload_file_id: 'upload-2',
url: 'https://example.com/summary.md',
}),
],
}}
/>,
)
expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf, summary.md')
})
it('renders structured outputs inside the code editor when height is available', () => {
render(<OutputPanel height={200} outputs={{ answer: 'hello', score: 1 }} />)
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(<OutputPanel height={0} outputs={{ answer: 'hello', score: 1 }} />)
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
})
})

View File

@@ -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: () => <div data-testid="loading-anim" />,
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileList: ({ files }: { files: FileEntity[] }) => (
<div data-testid="file-list">{files.map(file => file.name).join(', ')}</div>
),
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
}))
vi.mock('@/app/components/workflow/run/status-container', () => ({
default: ({ status, children }: { status: string, children?: React.ReactNode }) => (
<div data-status={status} data-testid="status-container">{children}</div>
),
}))
describe('ResultText', () => {
it('renders the loading animation while waiting for a text result', () => {
render(<ResultText isRunning />)
expect(screen.getByTestId('loading-anim')).toBeInTheDocument()
})
it('renders the error state when the run fails', () => {
render(<ResultText error="Run failed" />)
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(<ResultText onClick={onClick} />)
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(<ResultText isPaused />)
expect(screen.queryByText('runLog.resultEmpty.title')).not.toBeInTheDocument()
})
it('renders markdown content when text outputs are available', () => {
render(<ResultText outputs="hello workflow" />)
expect(screen.getByTestId('markdown')).toHaveTextContent('hello workflow')
})
it('renders file groups when file outputs are available', () => {
render(
<ResultText
allFiles={[
{
varName: 'attachments',
list: [
{
id: 'file-1',
name: 'report.pdf',
size: 128,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
} satisfies FileEntity,
],
},
]}
/>,
)
expect(screen.getByText('attachments')).toBeInTheDocument()
expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf')
})
})

View File

@@ -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> = {}): 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(<Status status="running" workflowRunId="run-1" />)
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(<Status status="running" isListening workflowRunId="run-2" />)
expect(screen.getByText('Listening')).toBeInTheDocument()
})
it('renders succeeded metadata values', () => {
render(<Status status="succeeded" time={1.234} tokens={8} />)
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(<Status status="stopped" />)
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(<Status status="failed" error="Something broke" exceptionCounts={2} />)
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(<Status status="partial-succeeded" exceptionCounts={3} />)
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(<Status status="exception" error="Bad request" />)
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(<Status status="paused" workflowRunId="run-3" />)
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(<Status status="paused" workflowRunId="run-4" />)
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')
})
})

View File

@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import Empty from '../empty'
const mockDocLink = vi.fn((path: string) => `https://docs.example.com${path}`)
vi.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
}))
describe('VariableInspect Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the empty-state copy and docs link', () => {
render(<Empty />)
const link = screen.getByRole('link', { name: 'workflow.debug.variableInspect.emptyLink' })
expect(screen.getByText('workflow.debug.variableInspect.title')).toBeInTheDocument()
expect(screen.getByText('workflow.debug.variableInspect.emptyTip')).toBeInTheDocument()
expect(link).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/variable-inspect')
expect(link).toHaveAttribute('target', '_blank')
expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/variable-inspect')
})
})

View File

@@ -0,0 +1,131 @@
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { VarInInspectType } from '@/types/workflow'
import { BlockEnum, VarType } from '../../types'
import Group from '../group'
const mockUseToolIcon = vi.fn(() => '')
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
useToolIcon: () => mockUseToolIcon(),
}
})
const createVar = (overrides: Partial<VarInInspect> = {}): VarInInspect => ({
id: 'var-1',
type: VarInInspectType.node,
name: 'message',
description: '',
selector: ['node-1', 'message'],
value_type: VarType.string,
value: 'hello',
edited: false,
visible: true,
is_truncated: false,
full_content: {
size_bytes: 0,
download_url: '',
},
...overrides,
})
const createNodeData = (overrides: Partial<NodeWithVar> = {}): NodeWithVar => ({
nodeId: 'node-1',
nodePayload: {
type: BlockEnum.Code,
title: 'Code',
desc: '',
},
nodeType: BlockEnum.Code,
title: 'Code',
vars: [],
...overrides,
})
describe('VariableInspect Group', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should mask secret environment variables before selecting them', () => {
const handleSelect = vi.fn()
render(
<Group
varType={VarInInspectType.environment}
varList={[
createVar({
id: 'env-secret',
type: VarInInspectType.environment,
name: 'API_KEY',
value_type: VarType.secret,
value: 'plain-secret',
}),
]}
handleSelect={handleSelect}
/>,
)
fireEvent.click(screen.getByText('API_KEY'))
expect(screen.getByText('workflow.debug.variableInspect.envNode')).toBeInTheDocument()
expect(handleSelect).toHaveBeenCalledWith({
nodeId: VarInInspectType.environment,
nodeType: VarInInspectType.environment,
title: VarInInspectType.environment,
var: expect.objectContaining({
id: 'env-secret',
type: VarInInspectType.environment,
value: '******************',
}),
})
})
it('should hide invisible variables and collapse the list when the group header is clicked', () => {
render(
<Group
nodeData={createNodeData()}
varType={VarInInspectType.node}
varList={[
createVar({ id: 'visible-var', name: 'visible_var' }),
createVar({ id: 'hidden-var', name: 'hidden_var', visible: false }),
]}
handleSelect={vi.fn()}
/>,
)
expect(screen.getByText('visible_var')).toBeInTheDocument()
expect(screen.queryByText('hidden_var')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Code'))
expect(screen.queryByText('visible_var')).not.toBeInTheDocument()
})
it('should expose node view and clear actions for node groups', () => {
const handleView = vi.fn()
const handleClear = vi.fn()
render(
<Group
nodeData={createNodeData()}
varType={VarInInspectType.node}
varList={[createVar()]}
handleSelect={vi.fn()}
handleView={handleView}
handleClear={handleClear}
/>,
)
const actionButtons = screen.getAllByRole('button')
fireEvent.click(actionButtons[0])
fireEvent.click(actionButtons[1])
expect(handleView).toHaveBeenCalledTimes(1)
expect(handleClear).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react'
import LargeDataAlert from '../large-data-alert'
describe('LargeDataAlert', () => {
it('should render the default message and export action when a download URL exists', () => {
const { container } = render(<LargeDataAlert downloadUrl="https://example.com/export.json" className="extra-alert" />)
expect(screen.getByText('workflow.debug.variableInspect.largeData')).toBeInTheDocument()
expect(screen.getByText('workflow.debug.variableInspect.export')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('extra-alert')
})
it('should render the no-export message and omit the export action when the URL is missing', () => {
render(<LargeDataAlert textHasNoExport />)
expect(screen.getByText('workflow.debug.variableInspect.largeDataNoExport')).toBeInTheDocument()
expect(screen.queryByText('workflow.debug.variableInspect.export')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,177 @@
import type { EnvironmentVariable } from '../../types'
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import Panel from '../panel'
import { EVENT_WORKFLOW_STOP } from '../types'
type InspectVarsState = {
conversationVars: VarInInspect[]
systemVars: VarInInspect[]
nodesWithInspectVars: NodeWithVar[]
}
const {
mockEditInspectVarValue,
mockEmit,
mockFetchInspectVarValue,
mockHandleNodeSelect,
mockResetConversationVar,
mockResetToLastRunVar,
mockSetInputs,
} = vi.hoisted(() => ({
mockEditInspectVarValue: vi.fn(),
mockEmit: vi.fn(),
mockFetchInspectVarValue: vi.fn(),
mockHandleNodeSelect: vi.fn(),
mockResetConversationVar: vi.fn(),
mockResetToLastRunVar: vi.fn(),
mockSetInputs: vi.fn(),
}))
let inspectVarsState: InspectVarsState
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
default: () => ({
...inspectVarsState,
deleteAllInspectorVars: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
editInspectVarValue: mockEditInspectVarValue,
fetchInspectVarValue: mockFetchInspectVarValue,
resetConversationVar: mockResetConversationVar,
resetToLastRunVar: mockResetToLastRunVar,
}),
}))
vi.mock('../../nodes/_base/components/variable/use-match-schema-type', () => ({
default: () => ({
isLoading: false,
schemaTypeDefinitions: {},
}),
}))
vi.mock('../../hooks/use-nodes-interactions', () => ({
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
useToolIcon: () => '',
}
})
vi.mock('../../nodes/_base/hooks/use-node-crud', () => ({
default: () => ({
setInputs: mockSetInputs,
}),
}))
vi.mock('../../nodes/_base/hooks/use-node-info', () => ({
default: () => ({
node: undefined,
}),
}))
vi.mock('../../hooks-store', () => ({
useHooksStore: <T,>(selector: (state: { configsMap?: { flowId: string } }) => T) =>
selector({
configsMap: {
flowId: 'flow-1',
},
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
const createEnvironmentVariable = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'API_KEY',
value: 'env-value',
value_type: 'string',
description: '',
...overrides,
})
const renderPanel = (initialStoreState: Record<string, unknown> = {}) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow fitView nodes={[]} edges={[]} />
<Panel />
</ReactFlowProvider>
</div>,
{
initialStoreState,
historyStore: {
nodes: [],
edges: [],
},
},
)
}
describe('VariableInspect Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
inspectVarsState = {
conversationVars: [],
systemVars: [],
nodesWithInspectVars: [],
}
})
it('should render the listening state and stop the workflow on demand', () => {
renderPanel({
isListening: true,
listeningTriggerType: BlockEnum.TriggerWebhook,
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.listening.stopButton' }))
expect(screen.getByText('workflow.debug.variableInspect.listening.title')).toBeInTheDocument()
expect(mockEmit).toHaveBeenCalledWith({
type: EVENT_WORKFLOW_STOP,
})
})
it('should render the empty state and close the panel from the header action', () => {
const { store } = renderPanel({
showVariableInspectPanel: true,
})
fireEvent.click(screen.getAllByRole('button')[0])
expect(screen.getByText('workflow.debug.variableInspect.emptyTip')).toBeInTheDocument()
expect(store.getState().showVariableInspectPanel).toBe(false)
})
it('should select an environment variable and show its details in the right panel', async () => {
renderPanel({
environmentVariables: [createEnvironmentVariable()],
bottomPanelWidth: 560,
})
fireEvent.click(screen.getByText('API_KEY'))
await waitFor(() => expect(screen.getAllByText('API_KEY').length).toBeGreaterThan(1))
expect(screen.getByText('workflow.debug.variableInspect.envNode')).toBeInTheDocument()
expect(screen.getAllByText('string').length).toBeGreaterThan(0)
expect(screen.getByText('env-value')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,170 @@
import type { Node as ReactFlowNode } from 'reactflow'
import type { CommonNodeType } from '../../types'
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import { fireEvent, screen } from '@testing-library/react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { VarInInspectType } from '@/types/workflow'
import { baseRunningData, renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus, VarType, WorkflowRunningStatus } from '../../types'
import VariableInspectTrigger from '../trigger'
type InspectVarsState = {
conversationVars: VarInInspect[]
systemVars: VarInInspect[]
nodesWithInspectVars: NodeWithVar[]
}
const {
mockDeleteAllInspectorVars,
mockEmit,
} = vi.hoisted(() => ({
mockDeleteAllInspectorVars: vi.fn(),
mockEmit: vi.fn(),
}))
let inspectVarsState: InspectVarsState
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
default: () => ({
...inspectVarsState,
deleteAllInspectorVars: mockDeleteAllInspectorVars,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
const createVariable = (overrides: Partial<VarInInspect> = {}): VarInInspect => ({
id: 'var-1',
type: VarInInspectType.node,
name: 'result',
description: '',
selector: ['node-1', 'result'],
value_type: VarType.string,
value: 'cached',
edited: false,
visible: true,
is_truncated: false,
full_content: {
size_bytes: 0,
download_url: '',
},
...overrides,
})
const createNode = (overrides: Partial<CommonNodeType> = {}): ReactFlowNode<CommonNodeType> => ({
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Code,
title: 'Code',
desc: '',
...overrides,
},
})
const renderTrigger = ({
nodes = [createNode()],
initialStoreState = {},
}: {
nodes?: Array<ReactFlowNode<CommonNodeType>>
initialStoreState?: Record<string, unknown>
} = {}) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow fitView nodes={nodes} edges={[]} />
<VariableInspectTrigger />
</ReactFlowProvider>
</div>,
{
initialStoreState,
},
)
}
describe('VariableInspectTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
inspectVarsState = {
conversationVars: [],
systemVars: [],
nodesWithInspectVars: [],
}
})
it('should stay hidden when the variable-inspect panel is already open', () => {
renderTrigger({
initialStoreState: {
showVariableInspectPanel: true,
},
})
expect(screen.queryByText('workflow.debug.variableInspect.trigger.normal')).not.toBeInTheDocument()
})
it('should open the panel from the normal trigger state', () => {
const { store } = renderTrigger()
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.normal'))
expect(store.getState().showVariableInspectPanel).toBe(true)
})
it('should block opening while the workflow is read only', () => {
const { store } = renderTrigger({
initialStoreState: {
isRestoring: true,
},
})
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.normal'))
expect(store.getState().showVariableInspectPanel).toBe(false)
})
it('should clear cached variables and reset the focused node', () => {
inspectVarsState = {
conversationVars: [createVariable({
id: 'conversation-var',
type: VarInInspectType.conversation,
})],
systemVars: [],
nodesWithInspectVars: [],
}
const { store } = renderTrigger({
initialStoreState: {
currentFocusNodeId: 'node-2',
},
})
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.clear'))
expect(screen.getByText('workflow.debug.variableInspect.trigger.cached')).toBeInTheDocument()
expect(mockDeleteAllInspectorVars).toHaveBeenCalledTimes(1)
expect(store.getState().currentFocusNodeId).toBe('')
})
it('should show the running state and open the panel while running', () => {
const { store } = renderTrigger({
nodes: [createNode({ _singleRunningStatus: NodeRunningStatus.Running })],
initialStoreState: {
workflowRunningData: baseRunningData({
result: { status: WorkflowRunningStatus.Running },
}),
},
})
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.running'))
expect(screen.queryByText('workflow.debug.variableInspect.trigger.clear')).not.toBeInTheDocument()
expect(store.getState().showVariableInspectPanel).toBe(true)
})
})

View File

@@ -0,0 +1,47 @@
import { render, waitFor } from '@testing-library/react'
import WorkflowPreview from '../index'
const defaultViewport = {
x: 0,
y: 0,
zoom: 1,
}
describe('WorkflowPreview', () => {
it('should render the preview container with the default left minimap placement', async () => {
const { container } = render(
<div style={{ width: 800, height: 600 }}>
<WorkflowPreview
nodes={[]}
edges={[]}
viewport={defaultViewport}
className="preview-shell"
/>
</div>,
)
await waitFor(() => expect(container.querySelector('.react-flow__minimap')).toBeInTheDocument())
expect(container.querySelector('#workflow-container')).toHaveClass('preview-shell')
expect(container.querySelector('.react-flow__background')).toBeInTheDocument()
expect(container.querySelector('.react-flow__minimap')).toHaveClass('!left-4')
})
it('should move the minimap to the right when requested', async () => {
const { container } = render(
<div style={{ width: 800, height: 600 }}>
<WorkflowPreview
nodes={[]}
edges={[]}
viewport={defaultViewport}
miniMapToRight
/>
</div>,
)
await waitFor(() => expect(container.querySelector('.react-flow__minimap')).toBeInTheDocument())
expect(container.querySelector('.react-flow__minimap')).toHaveClass('!right-4')
expect(container.querySelector('.react-flow__minimap')).not.toHaveClass('!left-4')
})
})

View File

@@ -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> = {}): CommonNodeType => ({
type: BlockEnum.Code,
title: 'Node',
desc: '',
...overrides,
})
const ErrorNode = ({ id, data }: NodeProps<CommonNodeType>) => (
<div>
<ErrorHandleOnNode id={id} data={data} />
</div>
)
const renderErrorNode = (data: CommonNodeType) => {
return render(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow
fitView
edges={[]}
nodes={[
{
id: 'node-1',
type: 'errorNode',
position: { x: 0, y: 0 },
data,
},
]}
nodeTypes={{ errorNode: ErrorNode }}
/>
</ReactFlowProvider>
</div>,
)
}
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()
})
})
})

View File

@@ -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> = {}): CommonNodeType => ({
type: BlockEnum.Code,
title: 'Node',
desc: '',
...overrides,
})
const TargetHandleNode = ({ id, data }: NodeProps<CommonNodeType>) => (
<div>
<NodeTargetHandle
id={id}
data={data}
handleId="target-1"
handleClassName="target-marker"
/>
</div>
)
const SourceHandleNode = ({ id, data }: NodeProps<CommonNodeType>) => (
<div>
<NodeSourceHandle
id={id}
data={data}
handleId="source-1"
handleClassName="source-marker"
/>
</div>
)
const renderFlowNode = (type: 'targetNode' | 'sourceNode', data: CommonNodeType) => {
return render(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow
fitView
edges={[]}
nodes={[
{
id: 'node-1',
type,
position: { x: 0, y: 0 },
data,
},
]}
nodeTypes={{
targetNode: TargetHandleNode,
sourceNode: SourceHandleNode,
}}
/>
</ReactFlowProvider>
</div>,
)
}
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(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow
fitView
edges={[]}
nodes={[
{
id: 'node-2',
type: 'targetNode',
position: { x: 0, y: 0 },
data: createNodeData({ type: BlockEnum.Start }),
},
]}
nodeTypes={{
targetNode: ({ id, data }: NodeProps<CommonNodeType>) => (
<div>
<NodeTargetHandle
id={id}
data={data}
handleId="target-2"
handleClassName="custom-target"
/>
</div>
),
}}
/>
</ReactFlowProvider>
</div>,
)
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')
})
})
})

View File

@@ -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