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