diff --git a/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx new file mode 100644 index 0000000000..7156495a59 --- /dev/null +++ b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx @@ -0,0 +1,410 @@ +import type { Edge, Node } from '../types' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useEffect } from 'react' +import { useEdges, useNodes, useStoreApi } from 'reactflow' +import { createEdge, createNode } from '../__tests__/fixtures' +import { renderWorkflowFlowComponent } from '../__tests__/workflow-test-env' +import EdgeContextmenu from '../edge-contextmenu' +import { useEdgesInteractions } from '../hooks/use-edges-interactions' + +const mockSaveStateToHistory = vi.fn() + +vi.mock('../hooks/use-workflow-history', () => ({ + useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }), + WorkflowHistoryEvent: { + EdgeDelete: 'EdgeDelete', + EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch', + EdgeSourceHandleChange: 'EdgeSourceHandleChange', + }, +})) + +vi.mock('../hooks/use-workflow', () => ({ + useNodesReadOnly: () => ({ + getNodesReadOnly: () => false, + }), +})) + +vi.mock('../utils', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})), + } +}) + +vi.mock('../hooks', async () => { + const { useEdgesInteractions } = await import('../hooks/use-edges-interactions') + const { usePanelInteractions } = await import('../hooks/use-panel-interactions') + + return { + useEdgesInteractions, + usePanelInteractions, + } +}) + +type EdgeRuntimeState = { + _hovering?: boolean + _isBundled?: boolean +} + +type NodeRuntimeState = { + selected?: boolean + _isBundled?: boolean +} + +const getEdgeRuntimeState = (edge?: Edge): EdgeRuntimeState => + (edge?.data ?? {}) as EdgeRuntimeState + +const getNodeRuntimeState = (node?: Node): NodeRuntimeState => + (node?.data ?? {}) as NodeRuntimeState + +function createFlowNodes() { + return [ + createNode({ id: 'n1' }), + createNode({ id: 'n2', position: { x: 100, y: 0 } }), + ] +} + +function createFlowEdges() { + return [ + createEdge({ + id: 'e1', + source: 'n1', + target: 'n2', + sourceHandle: 'branch-a', + data: { _hovering: false }, + selected: true, + }), + createEdge({ + id: 'e2', + source: 'n1', + target: 'n2', + sourceHandle: 'branch-b', + data: { _hovering: false }, + }), + ] +} + +let latestNodes: Node[] = [] +let latestEdges: Edge[] = [] + +const RuntimeProbe = () => { + latestNodes = useNodes() as Node[] + latestEdges = useEdges() as Edge[] + + return null +} + +const hooksStoreProps = { + doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), +} + +const EdgeMenuHarness = () => { + const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions() + const edges = useEdges() as Edge[] + const reactFlowStore = useStoreApi() + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Delete' && e.key !== 'Backspace') + return + + e.preventDefault() + handleEdgeDelete() + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [handleEdgeDelete]) + + return ( +
+ + + + + +
+ ) +} + +function renderEdgeMenu(options?: { + nodes?: Node[] + edges?: Edge[] + initialStoreState?: Record +}) { + const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {} + + return renderWorkflowFlowComponent(, { + nodes, + edges, + initialStoreState, + hooksStoreProps, + reactFlowProps: { fitView: false }, + }) +} + +describe('EdgeContextmenu', () => { + beforeEach(() => { + vi.clearAllMocks() + latestNodes = [] + latestEdges = [] + }) + + it('should not render when edgeMenu is absent', () => { + renderWorkflowFlowComponent(, { + nodes: createFlowNodes(), + edges: createFlowEdges(), + hooksStoreProps, + reactFlowProps: { fitView: false }, + }) + + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + + it('should delete the menu edge and close the menu when another edge is selected', async () => { + const user = userEvent.setup() + const { store } = renderEdgeMenu({ + edges: [ + createEdge({ + id: 'e1', + source: 'n1', + target: 'n2', + sourceHandle: 'branch-a', + selected: true, + data: { _hovering: false }, + }), + createEdge({ + id: 'e2', + source: 'n1', + target: 'n2', + sourceHandle: 'branch-b', + selected: false, + data: { _hovering: false }, + }), + ], + initialStoreState: { + edgeMenu: { + clientX: 320, + clientY: 180, + edgeId: 'e2', + }, + }, + }) + + const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i }) + expect(screen.getByText(/^del$/i)).toBeInTheDocument() + + await user.click(deleteAction) + + await waitFor(() => { + expect(latestEdges).toHaveLength(1) + expect(latestEdges[0].id).toBe('e1') + expect(latestEdges[0].selected).toBe(true) + expect(store.getState().edgeMenu).toBeUndefined() + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') + }) + + it('should not render the menu when the referenced edge no longer exists', () => { + renderWorkflowFlowComponent(, { + nodes: createFlowNodes(), + edges: createFlowEdges(), + initialStoreState: { + edgeMenu: { + clientX: 320, + clientY: 180, + edgeId: 'missing-edge', + }, + }, + hooksStoreProps, + reactFlowProps: { fitView: false }, + }) + + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + + it('should open the edge menu at the right-click position', async () => { + const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') + + renderEdgeMenu() + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { + clientX: 320, + clientY: 180, + }) + + expect(await screen.findByRole('menu')).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument() + expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ + x: 320, + y: 180, + width: 0, + height: 0, + })) + }) + + it('should delete the right-clicked edge and close the menu when delete is clicked', async () => { + const user = userEvent.setup() + + renderEdgeMenu() + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { + clientX: 320, + clientY: 180, + }) + + await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i })) + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(latestEdges.map(edge => edge.id)).toEqual(['e1']) + }) + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') + }) + + it.each([ + ['Delete', 'Delete'], + ['Backspace', 'Backspace'], + ])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => { + renderEdgeMenu({ + nodes: [ + createNode({ + id: 'n1', + selected: true, + data: { selected: true, _isBundled: true }, + }), + createNode({ + id: 'n2', + position: { x: 100, y: 0 }, + }), + ], + }) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { + clientX: 240, + clientY: 120, + }) + + expect(await screen.findByRole('menu')).toBeInTheDocument() + + fireEvent.keyDown(document.body, { key }) + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(latestEdges.map(edge => edge.id)).toEqual(['e1']) + expect(latestNodes.map(node => node.id)).toEqual(['n1', 'n2']) + expect(latestNodes.every(node => !node.selected && !getNodeRuntimeState(node).selected)).toBe(true) + }) + }) + + it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => { + renderEdgeMenu({ + nodes: [ + createNode({ + id: 'n1', + selected: true, + data: { selected: true, _isBundled: true }, + }), + createNode({ + id: 'n2', + position: { x: 100, y: 0 }, + selected: true, + data: { selected: true, _isBundled: true }, + }), + ], + }) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), { + clientX: 200, + clientY: 100, + }) + + expect(await screen.findByRole('menu')).toBeInTheDocument() + + fireEvent.keyDown(document.body, { key: 'Delete' }) + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(latestEdges.map(edge => edge.id)).toEqual(['e2']) + expect(latestNodes).toHaveLength(2) + expect(latestNodes.every(node => + !node.selected + && !getNodeRuntimeState(node).selected + && !getNodeRuntimeState(node)._isBundled, + )).toBe(true) + }) + }) + + it('should retarget the menu and selected edge when right-clicking a different edge', async () => { + const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') + + renderEdgeMenu() + const edgeOneButton = screen.getByLabelText('Right-click edge e1') + const edgeTwoButton = screen.getByLabelText('Right-click edge e2') + + fireEvent.contextMenu(edgeOneButton, { + clientX: 80, + clientY: 60, + }) + expect(await screen.findByRole('menu')).toBeInTheDocument() + + fireEvent.contextMenu(edgeTwoButton, { + clientX: 360, + clientY: 240, + }) + + await waitFor(() => { + expect(screen.getAllByRole('menu')).toHaveLength(1) + expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ + x: 360, + y: 240, + })) + expect(latestEdges.find(edge => edge.id === 'e1')?.selected).toBe(false) + expect(latestEdges.find(edge => edge.id === 'e2')?.selected).toBe(true) + expect(latestEdges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true) + }) + }) + + it('should hide the menu when the target edge disappears after opening it', async () => { + const { container } = renderEdgeMenu() + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), { + clientX: 160, + clientY: 100, + }) + expect(await screen.findByRole('menu')).toBeInTheDocument() + + fireEvent.click(container.querySelector('button[aria-label="Remove edge e1"]') as HTMLButtonElement) + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/__tests__/features.spec.tsx b/web/app/components/workflow/__tests__/features.spec.tsx index d7e2cb13ae..8be40faea9 100644 --- a/web/app/components/workflow/__tests__/features.spec.tsx +++ b/web/app/components/workflow/__tests__/features.spec.tsx @@ -2,11 +2,11 @@ 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 { useNodes } from 'reactflow' import Features from '../features' import { InputVarType } from '../types' import { createStartNode } from './fixtures' -import { renderWorkflowComponent } from './workflow-test-env' +import { renderWorkflowFlowComponent } from './workflow-test-env' const mockHandleSyncWorkflowDraft = vi.fn() const mockHandleAddVariable = vi.fn() @@ -112,17 +112,15 @@ const DelayedFeatures = () => { return } -const renderFeatures = (options?: Parameters[1]) => { - return renderWorkflowComponent( -
- - - - -
, - options, +const renderFeatures = (options?: Omit[1], 'nodes' | 'edges'>) => + renderWorkflowFlowComponent( + , + { + nodes: [startNode], + edges: [], + ...options, + }, ) -} describe('Features', () => { beforeEach(() => { diff --git a/web/app/components/workflow/__tests__/fixtures.ts b/web/app/components/workflow/__tests__/fixtures.ts index ebc1d0d300..a340e38abb 100644 --- a/web/app/components/workflow/__tests__/fixtures.ts +++ b/web/app/components/workflow/__tests__/fixtures.ts @@ -42,6 +42,13 @@ export function createStartNode(overrides: Omit, 'data'> & { data? }) } +export function createNodeDataFactory>(defaults: T) { + return (overrides: Partial = {}): T => ({ + ...defaults, + ...overrides, + }) +} + export function createTriggerNode( triggerType: BlockEnum.TriggerSchedule | BlockEnum.TriggerWebhook | BlockEnum.TriggerPlugin = BlockEnum.TriggerWebhook, overrides: Omit, 'data'> & { data?: Partial & Record } = {}, diff --git a/web/app/components/workflow/__tests__/i18n.ts b/web/app/components/workflow/__tests__/i18n.ts new file mode 100644 index 0000000000..7d04667a32 --- /dev/null +++ b/web/app/components/workflow/__tests__/i18n.ts @@ -0,0 +1,9 @@ +import { vi } from 'vitest' + +export function resolveDocLink(path: string, baseUrl = 'https://docs.example.com') { + return `${baseUrl}${path}` +} + +export function createDocLinkMock(baseUrl = 'https://docs.example.com') { + return vi.fn((path: string) => resolveDocLink(path, baseUrl)) +} diff --git a/web/app/components/workflow/__tests__/model-provider-fixtures.spec.ts b/web/app/components/workflow/__tests__/model-provider-fixtures.spec.ts new file mode 100644 index 0000000000..4c728cccf3 --- /dev/null +++ b/web/app/components/workflow/__tests__/model-provider-fixtures.spec.ts @@ -0,0 +1,179 @@ +import { + ConfigurationMethodEnum, + CurrentSystemQuotaTypeEnum, + CustomConfigurationStatusEnum, + ModelStatusEnum, + ModelTypeEnum, + PreferredProviderTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + createCredentialState, + createDefaultModel, + createModel, + createModelItem, + createProviderMeta, +} from './model-provider-fixtures' + +describe('model-provider-fixtures', () => { + describe('createModelItem', () => { + it('should return the default text embedding model item', () => { + expect(createModelItem()).toEqual({ + model: 'text-embedding-3-large', + label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' }, + model_type: ModelTypeEnum.textEmbedding, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }) + }) + + it('should allow overriding the default model item fields', () => { + expect(createModelItem({ + model: 'bge-large', + status: ModelStatusEnum.disabled, + load_balancing_enabled: true, + })).toEqual(expect.objectContaining({ + model: 'bge-large', + status: ModelStatusEnum.disabled, + load_balancing_enabled: true, + })) + }) + }) + + describe('createModel', () => { + it('should build an active provider model with one default model item', () => { + const result = createModel() + + expect(result.provider).toBe('openai') + expect(result.status).toBe(ModelStatusEnum.active) + expect(result.models).toHaveLength(1) + expect(result.models[0]).toEqual(createModelItem()) + }) + + it('should use override values for provider metadata and model list', () => { + const customModelItem = createModelItem({ + model: 'rerank-v1', + model_type: ModelTypeEnum.rerank, + }) + + expect(createModel({ + provider: 'cohere', + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [customModelItem], + })).toEqual(expect.objectContaining({ + provider: 'cohere', + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [customModelItem], + })) + }) + }) + + describe('createDefaultModel', () => { + it('should return the default provider and model selection', () => { + expect(createDefaultModel()).toEqual({ + provider: 'openai', + model: 'text-embedding-3-large', + }) + }) + + it('should allow overriding the default provider selection', () => { + expect(createDefaultModel({ + provider: 'azure_openai', + model: 'text-embedding-3-small', + })).toEqual({ + provider: 'azure_openai', + model: 'text-embedding-3-small', + }) + }) + }) + + describe('createProviderMeta', () => { + it('should return provider metadata with credential and system configuration defaults', () => { + expect(createProviderMeta()).toEqual({ + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + help: { + title: { en_US: 'Help', zh_Hans: 'Help' }, + url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' }, + }, + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' }, + supported_model_types: [ModelTypeEnum.textEmbedding], + configurate_methods: [ConfigurationMethodEnum.predefinedModel], + provider_credential_schema: { + credential_form_schemas: [], + }, + model_credential_schema: { + model: { + label: { en_US: 'Model', zh_Hans: 'Model' }, + placeholder: { en_US: 'Select model', zh_Hans: 'Select model' }, + }, + credential_form_schemas: [], + }, + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [], + }, + }) + }) + + it('should apply provider metadata overrides', () => { + expect(createProviderMeta({ + provider: 'bedrock', + supported_model_types: [ModelTypeEnum.textGeneration], + preferred_provider_type: PreferredProviderTypeEnum.system, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_configurations: [], + }, + })).toEqual(expect.objectContaining({ + provider: 'bedrock', + supported_model_types: [ModelTypeEnum.textGeneration], + preferred_provider_type: PreferredProviderTypeEnum.system, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.paid, + quota_configurations: [], + }, + })) + }) + }) + + describe('createCredentialState', () => { + it('should return the default active credential panel state', () => { + expect(createCredentialState()).toEqual({ + variant: 'api-active', + priority: 'apiKeyOnly', + supportsCredits: false, + showPrioritySwitcher: false, + isCreditsExhausted: false, + hasCredentials: true, + credentialName: undefined, + credits: 0, + }) + }) + + it('should allow overriding the credential panel state', () => { + expect(createCredentialState({ + variant: 'credits-active', + supportsCredits: true, + showPrioritySwitcher: true, + credits: 12, + credentialName: 'Primary Key', + })).toEqual(expect.objectContaining({ + variant: 'credits-active', + supportsCredits: true, + showPrioritySwitcher: true, + credits: 12, + credentialName: 'Primary Key', + })) + }) + }) +}) diff --git a/web/app/components/workflow/__tests__/model-provider-fixtures.ts b/web/app/components/workflow/__tests__/model-provider-fixtures.ts new file mode 100644 index 0000000000..988ed8df64 --- /dev/null +++ b/web/app/components/workflow/__tests__/model-provider-fixtures.ts @@ -0,0 +1,97 @@ +import type { + DefaultModel, + Model, + ModelItem, + ModelProvider, +} from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { CredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state' +import { + ConfigurationMethodEnum, + CurrentSystemQuotaTypeEnum, + CustomConfigurationStatusEnum, + ModelStatusEnum, + ModelTypeEnum, + PreferredProviderTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' + +export function createModelItem(overrides: Partial = {}): ModelItem { + return { + model: 'text-embedding-3-large', + label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' }, + model_type: ModelTypeEnum.textEmbedding, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + ...overrides, + } +} + +export function createModel(overrides: Partial = {}): Model { + return { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [createModelItem()], + status: ModelStatusEnum.active, + ...overrides, + } +} + +export function createDefaultModel(overrides: Partial = {}): DefaultModel { + return { + provider: 'openai', + model: 'text-embedding-3-large', + ...overrides, + } +} + +export function createProviderMeta(overrides: Partial = {}): ModelProvider { + return { + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + help: { + title: { en_US: 'Help', zh_Hans: 'Help' }, + url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' }, + }, + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' }, + supported_model_types: [ModelTypeEnum.textEmbedding], + configurate_methods: [ConfigurationMethodEnum.predefinedModel], + provider_credential_schema: { + credential_form_schemas: [], + }, + model_credential_schema: { + model: { + label: { en_US: 'Model', zh_Hans: 'Model' }, + placeholder: { en_US: 'Select model', zh_Hans: 'Select model' }, + }, + credential_form_schemas: [], + }, + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [], + }, + ...overrides, + } +} + +export function createCredentialState(overrides: Partial = {}): CredentialPanelState { + return { + variant: 'api-active', + priority: 'apiKeyOnly', + supportsCredits: false, + showPrioritySwitcher: false, + isCreditsExhausted: false, + hasCredentials: true, + credentialName: undefined, + credits: 0, + ...overrides, + } +} diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 44bd1ea775..b926646433 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -1,16 +1,12 @@ -import type { EdgeChange, ReactFlowProps } from 'reactflow' import type { Edge, Node } from '../types' -import { act, fireEvent, screen } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { BaseEdge, internalsSymbol, Position, ReactFlowProvider, useStoreApi } from 'reactflow' import { FlowType } from '@/types/common' import { WORKFLOW_DATA_UPDATE } from '../constants' import { Workflow } from '../index' import { renderWorkflowComponent } from './workflow-test-env' -const reactFlowState = vi.hoisted(() => ({ - lastProps: null as ReactFlowProps | null, -})) - type WorkflowUpdateEvent = { type: string payload: { @@ -23,6 +19,10 @@ const eventEmitterState = vi.hoisted(() => ({ subscription: null as null | ((payload: WorkflowUpdateEvent) => void), })) +const reactFlowBridge = vi.hoisted(() => ({ + store: null as null | ReturnType, +})) + const workflowHookMocks = vi.hoisted(() => ({ handleNodeDragStart: vi.fn(), handleNodeDrag: vi.fn(), @@ -52,90 +52,64 @@ const workflowHookMocks = vi.hoisted(() => ({ useWorkflowSearch: vi.fn(), })) +function createInitializedNode(id: string, x: number, label: string) { + return { + id, + position: { x, y: 0 }, + positionAbsolute: { x, y: 0 }, + width: 160, + height: 40, + sourcePosition: Position.Right, + targetPosition: Position.Left, + data: { label }, + [internalsSymbol]: { + positionAbsolute: { x, y: 0 }, + handleBounds: { + source: [{ + id: null, + nodeId: id, + type: 'source', + position: Position.Right, + x: 160, + y: 0, + width: 0, + height: 40, + }], + target: [{ + id: null, + nodeId: id, + type: 'target', + position: Position.Left, + x: 0, + y: 0, + width: 0, + height: 40, + }], + }, + z: 0, + }, + } +} + const baseNodes = [ - { - id: 'node-1', - type: 'custom', - position: { x: 0, y: 0 }, - data: {}, - }, + createInitializedNode('node-1', 0, 'Workflow node node-1'), + createInitializedNode('node-2', 240, 'Workflow node node-2'), ] as unknown as Node[] const baseEdges = [ { id: 'edge-1', + type: 'custom', source: 'node-1', target: 'node-2', data: { sourceType: 'start', targetType: 'end' }, }, ] as unknown as Edge[] -const edgeChanges: EdgeChange[] = [{ id: 'edge-1', type: 'remove' }] - -function createMouseEvent() { - return { - preventDefault: vi.fn(), - clientX: 24, - clientY: 48, - } as unknown as React.MouseEvent -} - vi.mock('@/next/dynamic', () => ({ default: () => () => null, })) -vi.mock('reactflow', async () => { - const mod = await import('./reactflow-mock-state') - const base = mod.createReactFlowModuleMock() - const ReactFlowMock = (props: ReactFlowProps) => { - reactFlowState.lastProps = props - return React.createElement( - 'div', - { 'data-testid': 'reactflow-mock' }, - React.createElement('button', { - 'type': 'button', - 'aria-label': 'Emit edge mouse enter', - 'onClick': () => props.onEdgeMouseEnter?.(createMouseEvent(), baseEdges[0]), - }), - React.createElement('button', { - 'type': 'button', - 'aria-label': 'Emit edge mouse leave', - 'onClick': () => props.onEdgeMouseLeave?.(createMouseEvent(), baseEdges[0]), - }), - React.createElement('button', { - 'type': 'button', - 'aria-label': 'Emit edges change', - 'onClick': () => props.onEdgesChange?.(edgeChanges), - }), - React.createElement('button', { - 'type': 'button', - 'aria-label': 'Emit edge context menu', - 'onClick': () => props.onEdgeContextMenu?.(createMouseEvent(), baseEdges[0]), - }), - React.createElement('button', { - 'type': 'button', - 'aria-label': 'Emit node context menu', - 'onClick': () => props.onNodeContextMenu?.(createMouseEvent(), baseNodes[0]), - }), - React.createElement('button', { - 'type': 'button', - 'aria-label': 'Emit pane context menu', - 'onClick': () => props.onPaneContextMenu?.(createMouseEvent()), - }), - props.children, - ) - } - - return { - ...base, - SelectionMode: { - Partial: 'partial', - }, - ReactFlow: ReactFlowMock, - default: ReactFlowMock, - } -}) - vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: { @@ -166,7 +140,10 @@ vi.mock('../custom-connection-line', () => ({ })) vi.mock('../custom-edge', () => ({ - default: () => null, + default: () => React.createElement(BaseEdge, { + id: 'edge-1', + path: 'M 0 0 L 100 0', + }), })) vi.mock('../help-line', () => ({ @@ -182,7 +159,7 @@ vi.mock('../node-contextmenu', () => ({ })) vi.mock('../nodes', () => ({ - default: () => null, + default: ({ id }: { id: string }) => React.createElement('div', { 'data-testid': `workflow-node-${id}` }, `Workflow node ${id}`), })) vi.mock('../nodes/data-source-empty', () => ({ @@ -289,17 +266,24 @@ vi.mock('../nodes/_base/components/variable/use-match-schema-type', () => ({ }), })) -vi.mock('../workflow-history-store', () => ({ - WorkflowHistoryProvider: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children), -})) +function renderSubject(options?: { + nodes?: Node[] + edges?: Edge[] + initialStoreState?: Record +}) { + const { nodes = baseNodes, edges = baseEdges, initialStoreState } = options ?? {} -function renderSubject() { return renderWorkflowComponent( - , + + + + + , { + initialStoreState, hooksStoreProps: { configsMap: { flowId: 'flow-1', @@ -311,75 +295,106 @@ function renderSubject() { ) } +function ReactFlowEdgeBootstrap({ nodes, edges }: { nodes: Node[], edges: Edge[] }) { + const store = useStoreApi() + + React.useEffect(() => { + store.setState({ + edges, + width: 500, + height: 500, + nodeInternals: new Map(nodes.map(node => [node.id, node])), + }) + reactFlowBridge.store = store + + return () => { + reactFlowBridge.store = null + } + }, [edges, nodes, store]) + + return null +} + +function getPane(container: HTMLElement) { + const pane = container.querySelector('.react-flow__pane') as HTMLElement | null + + if (!pane) + throw new Error('Expected a rendered React Flow pane') + + return pane +} + describe('Workflow edge event wiring', () => { beforeEach(() => { vi.clearAllMocks() - reactFlowState.lastProps = null eventEmitterState.subscription = null + reactFlowBridge.store = null }) - it('should forward React Flow edge events to workflow handlers when emitted by the canvas', () => { - renderSubject() + it('should forward pane, node and edge-change events to workflow handlers when emitted by the canvas', async () => { + const { container } = renderSubject() + const pane = getPane(container) - fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse enter' })) - fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse leave' })) - fireEvent.click(screen.getByRole('button', { name: 'Emit edges change' })) - fireEvent.click(screen.getByRole('button', { name: 'Emit edge context menu' })) - fireEvent.click(screen.getByRole('button', { name: 'Emit node context menu' })) - fireEvent.click(screen.getByRole('button', { name: 'Emit pane context menu' })) + act(() => { + fireEvent.contextMenu(screen.getByText('Workflow node node-1'), { clientX: 24, clientY: 48 }) + fireEvent.contextMenu(pane, { clientX: 24, clientY: 48 }) + }) - expect(workflowHookMocks.handleEdgeEnter).toHaveBeenCalledWith(expect.objectContaining({ - clientX: 24, - clientY: 48, - }), baseEdges[0]) - expect(workflowHookMocks.handleEdgeLeave).toHaveBeenCalledWith(expect.objectContaining({ - clientX: 24, - clientY: 48, - }), baseEdges[0]) - expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(edgeChanges) - expect(workflowHookMocks.handleEdgeContextMenu).toHaveBeenCalledWith(expect.objectContaining({ - clientX: 24, - clientY: 48, - }), baseEdges[0]) - expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({ - clientX: 24, - clientY: 48, - }), baseNodes[0]) - expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({ - clientX: 24, - clientY: 48, - })) + await waitFor(() => { + expect(reactFlowBridge.store?.getState().onEdgesChange).toBeTypeOf('function') + }) + + act(() => { + reactFlowBridge.store?.getState().onEdgesChange?.([{ id: 'edge-1', type: 'select', selected: true }]) + }) + + await waitFor(() => { + expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ id: 'edge-1', type: 'select' }), + ])) + expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({ + clientX: 24, + clientY: 48, + }), expect.objectContaining({ id: 'node-1' })) + expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({ + clientX: 24, + clientY: 48, + })) + }) }) - it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', () => { - renderSubject() + it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', async () => { + renderSubject({ + edges: [ + { + ...baseEdges[0], + selected: true, + } as Edge, + ], + }) - expect(reactFlowState.lastProps?.deleteKeyCode).toBeNull() + act(() => { + fireEvent.keyDown(document.body, { key: 'Delete' }) + }) + + await waitFor(() => { + expect(screen.getByText('Workflow node node-1')).toBeInTheDocument() + }) + expect(workflowHookMocks.handleEdgesChange).not.toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ id: 'edge-1', type: 'remove' }), + ])) }) it('should clear edgeMenu when workflow data updates remove the current edge', () => { - const { store } = renderWorkflowComponent( - , - { - initialStoreState: { - edgeMenu: { - clientX: 320, - clientY: 180, - edgeId: 'edge-1', - }, - }, - hooksStoreProps: { - configsMap: { - flowId: 'flow-1', - flowType: FlowType.appFlow, - fileSettings: {}, - }, + const { store } = renderSubject({ + initialStoreState: { + edgeMenu: { + clientX: 320, + clientY: 180, + edgeId: 'edge-1', }, }, - ) + }) act(() => { eventEmitterState.subscription?.({ diff --git a/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx b/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx index d9a4efa12e..de13828f2a 100644 --- a/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx @@ -4,10 +4,17 @@ import type { Shape } from '../store/workflow' import { act, screen } from '@testing-library/react' import * as React from 'react' +import { useNodes } from 'reactflow' import { FlowType } from '@/types/common' import { useHooksStore } from '../hooks-store/store' import { useStore, useWorkflowStore } from '../store/workflow' -import { renderNodeComponent, renderWorkflowComponent } from './workflow-test-env' +import { createNode } from './fixtures' +import { + renderNodeComponent, + renderWorkflowComponent, + renderWorkflowFlowComponent, + renderWorkflowFlowHook, +} from './workflow-test-env' // --------------------------------------------------------------------------- // Test components that read from workflow contexts @@ -43,6 +50,12 @@ function NodeRenderer(props: { id: string, data: { title: string }, selected?: b ) } +function FlowReader() { + const nodes = useNodes() + const showConfirm = useStore(s => s.showConfirm) + return React.createElement('div', { 'data-testid': 'flow-reader' }, `${nodes.length}:${showConfirm ? 'confirm' : 'clear'}`) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -134,3 +147,30 @@ describe('renderNodeComponent', () => { expect(screen.getByTestId('node-store')).toHaveTextContent('test-node-1:hand') }) }) + +describe('renderWorkflowFlowComponent', () => { + it('should provide both ReactFlow and Workflow contexts', () => { + renderWorkflowFlowComponent(React.createElement(FlowReader), { + nodes: [ + createNode({ id: 'n-1' }), + createNode({ id: 'n-2' }), + ], + initialStoreState: { showConfirm: { title: 'Hey', onConfirm: () => {} } }, + }) + + expect(screen.getByTestId('flow-reader')).toHaveTextContent('2:confirm') + }) +}) + +describe('renderWorkflowFlowHook', () => { + it('should render hooks inside a real ReactFlow provider', () => { + const { result } = renderWorkflowFlowHook(() => useNodes(), { + nodes: [ + createNode({ id: 'flow-1' }), + ], + }) + + expect(result.current).toHaveLength(1) + expect(result.current[0].id).toBe('flow-1') + }) +}) diff --git a/web/app/components/workflow/__tests__/workflow-test-env.tsx b/web/app/components/workflow/__tests__/workflow-test-env.tsx index cd11b886a2..1ee601317b 100644 --- a/web/app/components/workflow/__tests__/workflow-test-env.tsx +++ b/web/app/components/workflow/__tests__/workflow-test-env.tsx @@ -69,6 +69,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, renderHook } from '@testing-library/react' import isDeepEqual from 'fast-deep-equal' import * as React from 'react' +import ReactFlow, { ReactFlowProvider } from 'reactflow' import { temporal } from 'zundo' import { create } from 'zustand' import { WorkflowContext } from '../context' @@ -252,6 +253,104 @@ export function renderWorkflowComponent( return { ...renderResult, ...stores } } +// --------------------------------------------------------------------------- +// renderWorkflowFlowComponent / renderWorkflowFlowHook — real ReactFlow wrappers +// --------------------------------------------------------------------------- + +type WorkflowFlowOptions = WorkflowProviderOptions & { + nodes?: Node[] + edges?: Edge[] + reactFlowProps?: Omit, 'children' | 'nodes' | 'edges'> + canvasStyle?: React.CSSProperties +} + +type WorkflowFlowComponentTestOptions = Omit & WorkflowFlowOptions +type WorkflowFlowHookTestOptions

= Omit, 'wrapper'> & WorkflowFlowOptions + +function createWorkflowFlowWrapper( + stores: StoreInstances, + { + historyStore: historyConfig, + nodes = [], + edges = [], + reactFlowProps, + canvasStyle, + }: WorkflowFlowOptions, +) { + const workflowWrapper = createWorkflowWrapper(stores, historyConfig) + + return ({ children }: { children: React.ReactNode }) => React.createElement( + workflowWrapper, + null, + React.createElement( + 'div', + { style: { width: 800, height: 600, ...canvasStyle } }, + React.createElement( + ReactFlowProvider, + null, + React.createElement(ReactFlow, { fitView: true, ...reactFlowProps, nodes, edges }), + children, + ), + ), + ) +} + +export function renderWorkflowFlowComponent( + ui: React.ReactElement, + options?: WorkflowFlowComponentTestOptions, +): WorkflowComponentTestResult { + const { + initialStoreState, + hooksStoreProps, + historyStore, + nodes, + edges, + reactFlowProps, + canvasStyle, + ...renderOptions + } = options ?? {} + + const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps }) + const wrapper = createWorkflowFlowWrapper(stores, { + historyStore, + nodes, + edges, + reactFlowProps, + canvasStyle, + }) + + const renderResult = render(ui, { wrapper, ...renderOptions }) + return { ...renderResult, ...stores } +} + +export function renderWorkflowFlowHook( + hook: (props: P) => R, + options?: WorkflowFlowHookTestOptions

, +): WorkflowHookTestResult { + const { + initialStoreState, + hooksStoreProps, + historyStore, + nodes, + edges, + reactFlowProps, + canvasStyle, + ...rest + } = options ?? {} + + const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps }) + const wrapper = createWorkflowFlowWrapper(stores, { + historyStore, + nodes, + edges, + reactFlowProps, + canvasStyle, + }) + + const renderResult = renderHook(hook, { wrapper, ...rest }) + return { ...renderResult, ...stores } +} + // --------------------------------------------------------------------------- // renderNodeComponent — convenience wrapper for node components // --------------------------------------------------------------------------- diff --git a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx new file mode 100644 index 0000000000..2b28662b45 --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx @@ -0,0 +1,277 @@ +import type { TriggerWithProvider } from '../types' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks' +import { CollectionType } from '@/app/components/tools/types' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useGetLanguage, useLocale } from '@/context/i18n' +import useTheme from '@/hooks/use-theme' +import { useFeaturedTriggersRecommendations } from '@/service/use-plugins' +import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' +import { Theme } from '@/types/app' +import { defaultSystemFeatures } from '@/types/feature' +import { useAvailableNodesMetaData } from '../../../workflow-app/hooks' +import useNodes from '../../store/workflow/use-nodes' +import { BlockEnum } from '../../types' +import AllStartBlocks from '../all-start-blocks' + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: vi.fn(), + useLocale: vi.fn(), +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: vi.fn(), +})) + +vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ + useMarketplacePlugins: vi.fn(), +})) + +vi.mock('@/service/use-triggers', () => ({ + useAllTriggerPlugins: vi.fn(), + useInvalidateAllTriggerPlugins: vi.fn(), +})) + +vi.mock('@/service/use-plugins', () => ({ + useFeaturedTriggersRecommendations: vi.fn(), +})) + +vi.mock('../../store/workflow/use-nodes', () => ({ + default: vi.fn(), +})) + +vi.mock('../../../workflow-app/hooks', () => ({ + useAvailableNodesMetaData: vi.fn(), +})) + +vi.mock('@/utils/var', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMarketplaceUrl: () => 'https://marketplace.test/start', + } +}) + +const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) +const mockUseGetLanguage = vi.mocked(useGetLanguage) +const mockUseLocale = vi.mocked(useLocale) +const mockUseTheme = vi.mocked(useTheme) +const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins) +const mockUseAllTriggerPlugins = vi.mocked(useAllTriggerPlugins) +const mockUseInvalidateAllTriggerPlugins = vi.mocked(useInvalidateAllTriggerPlugins) +const mockUseFeaturedTriggersRecommendations = vi.mocked(useFeaturedTriggersRecommendations) +const mockUseNodes = vi.mocked(useNodes) +const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData) + +type UseMarketplacePluginsReturn = ReturnType +type UseAllTriggerPluginsReturn = ReturnType +type UseFeaturedTriggersRecommendationsReturn = ReturnType + +const createTriggerProvider = (overrides: Partial = {}): TriggerWithProvider => ({ + id: 'provider-1', + name: 'provider-one', + author: 'Provider Author', + description: { en_US: 'desc', zh_Hans: '描述' }, + icon: 'icon', + icon_dark: 'icon-dark', + label: { en_US: 'Provider One', zh_Hans: '提供商一' }, + type: CollectionType.trigger, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + plugin_id: 'plugin-1', + plugin_unique_identifier: 'plugin-1@1.0.0', + meta: { version: '1.0.0' }, + credentials_schema: [], + subscription_constructor: null, + subscription_schema: [], + supported_creation_methods: [], + events: [ + { + name: 'created', + author: 'Provider Author', + label: { en_US: 'Created', zh_Hans: '创建' }, + description: { en_US: 'Created event', zh_Hans: '创建事件' }, + parameters: [], + labels: [], + output_schema: {}, + }, + ], + ...overrides, +}) + +const createSystemFeatures = (enableMarketplace: boolean) => ({ + ...defaultSystemFeatures, + enable_marketplace: enableMarketplace, +}) + +const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({ + systemFeatures: createSystemFeatures(enableMarketplace), + setSystemFeatures: vi.fn(), +}) + +const createMarketplacePluginsMock = ( + overrides: Partial = {}, +): UseMarketplacePluginsReturn => ({ + plugins: [], + total: 0, + resetPlugins: vi.fn(), + queryPlugins: vi.fn(), + queryPluginsWithDebounced: vi.fn(), + cancelQueryPluginsWithDebounced: vi.fn(), + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + page: 0, + ...overrides, +}) + +const createTriggerPluginsQueryResult = ( + data: TriggerWithProvider[], +): UseAllTriggerPluginsReturn => ({ + data, + error: null, + isError: false, + isPending: false, + isLoading: false, + isSuccess: true, + isFetching: false, + isRefetching: false, + isLoadingError: false, + isRefetchError: false, + isInitialLoading: false, + isPaused: false, + isEnabled: true, + status: 'success', + fetchStatus: 'idle', + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: Promise.resolve(data), +} as UseAllTriggerPluginsReturn) + +const createFeaturedTriggersRecommendationsMock = ( + overrides: Partial = {}, +): UseFeaturedTriggersRecommendationsReturn => ({ + plugins: [], + isLoading: false, + ...overrides, +}) + +const createAvailableNodesMetaData = (): ReturnType => ({ + nodes: [], +} as unknown as ReturnType) + +describe('AllStartBlocks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false))) + mockUseGetLanguage.mockReturnValue('en_US') + mockUseLocale.mockReturnValue('en_US') + mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType) + mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock()) + mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([createTriggerProvider()])) + mockUseInvalidateAllTriggerPlugins.mockReturnValue(vi.fn()) + mockUseFeaturedTriggersRecommendations.mockReturnValue(createFeaturedTriggersRecommendationsMock()) + mockUseNodes.mockReturnValue([]) + mockUseAvailableNodesMetaData.mockReturnValue(createAvailableNodesMetaData()) + }) + + // The combined start tab should merge built-in blocks, trigger plugins, and marketplace states. + describe('Content Rendering', () => { + it('should render start blocks and trigger plugin actions', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + render( + , + ) + + await waitFor(() => { + expect(screen.getByText('workflow.tabs.allTriggers')).toBeInTheDocument() + }) + + expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument() + expect(screen.getByText('Provider One')).toBeInTheDocument() + + await user.click(screen.getByText('workflow.blocks.start')) + expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start) + + await user.click(screen.getByText('Provider One')) + await user.click(screen.getByText('Created')) + + expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({ + provider_id: 'provider-one', + event_name: 'created', + })) + }) + + it('should show marketplace footer when marketplace is enabled without filters', async () => { + mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true))) + + render( + , + ) + + expect(await screen.findByRole('link', { name: /plugin\.findMoreInMarketplace/ })).toHaveAttribute('href', 'https://marketplace.test/start') + }) + }) + + // Empty filter states should surface the request-to-community fallback. + describe('Filtered Empty State', () => { + it('should query marketplace and show the no-results state when filters have no matches', async () => { + const queryPluginsWithDebounced = vi.fn() + mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true))) + mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({ + queryPluginsWithDebounced, + })) + mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([])) + + render( + , + ) + + await waitFor(() => { + expect(queryPluginsWithDebounced).toHaveBeenCalledWith({ + query: 'missing', + tags: ['webhook'], + category: 'trigger', + }) + }) + + expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'workflow.tabs.requestToCommunity' })).toHaveAttribute( + 'href', + 'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml', + ) + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx b/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx new file mode 100644 index 0000000000..64bcd514c6 --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx @@ -0,0 +1,186 @@ +import type { ToolWithProvider } from '../../types' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { CollectionType } from '@/app/components/tools/types' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useGetLanguage } from '@/context/i18n' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import { defaultSystemFeatures } from '@/types/feature' +import { BlockEnum } from '../../types' +import DataSources from '../data-sources' + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: vi.fn(), +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: vi.fn(), +})) + +vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ + useMarketplacePlugins: vi.fn(), +})) + +const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) +const mockUseGetLanguage = vi.mocked(useGetLanguage) +const mockUseTheme = vi.mocked(useTheme) +const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins) + +type UseMarketplacePluginsReturn = ReturnType + +const createToolProvider = (overrides: Partial = {}): ToolWithProvider => ({ + id: 'langgenius/file', + name: 'file', + author: 'Dify', + description: { en_US: 'desc', zh_Hans: '描述' }, + icon: 'icon', + label: { en_US: 'File Source', zh_Hans: '文件源' }, + type: CollectionType.datasource, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + plugin_id: 'langgenius/file', + meta: { version: '1.0.0' }, + tools: [ + { + name: 'local-file', + author: 'Dify', + label: { en_US: 'Local File', zh_Hans: '本地文件' }, + description: { en_US: 'Load local files', zh_Hans: '加载本地文件' }, + parameters: [], + labels: [], + output_schema: {}, + }, + ], + ...overrides, +}) + +const createSystemFeatures = (enableMarketplace: boolean) => ({ + ...defaultSystemFeatures, + enable_marketplace: enableMarketplace, +}) + +const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({ + systemFeatures: createSystemFeatures(enableMarketplace), + setSystemFeatures: vi.fn(), +}) + +const createMarketplacePluginsMock = ( + overrides: Partial = {}, +): UseMarketplacePluginsReturn => ({ + plugins: [], + total: 0, + resetPlugins: vi.fn(), + queryPlugins: vi.fn(), + queryPluginsWithDebounced: vi.fn(), + cancelQueryPluginsWithDebounced: vi.fn(), + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + page: 0, + ...overrides, +}) + +describe('DataSources', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false))) + mockUseGetLanguage.mockReturnValue('en_US') + mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType) + mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock()) + }) + + // Data source tools should filter by search and normalize the default value payload. + describe('Selection', () => { + it('should add default file extensions for the built-in local file data source', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('File Source')) + await user.click(screen.getByText('Local File')) + + expect(onSelect).toHaveBeenCalledWith(BlockEnum.DataSource, expect.objectContaining({ + provider_name: 'file', + datasource_name: 'local-file', + datasource_label: 'Local File', + fileExtensions: expect.arrayContaining(['txt', 'pdf', 'md']), + })) + }) + + it('should filter providers by search text', () => { + render( + , + ) + + expect(screen.getByText('Searchable Source')).toBeInTheDocument() + expect(screen.queryByText('Other Source')).not.toBeInTheDocument() + }) + }) + + // Marketplace search should only run when enabled and a search term is present. + describe('Marketplace Search', () => { + it('should query marketplace plugins for datasource search results', async () => { + const queryPluginsWithDebounced = vi.fn() + mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true))) + mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({ + queryPluginsWithDebounced, + })) + + render( + , + ) + + await waitFor(() => { + expect(queryPluginsWithDebounced).toHaveBeenCalledWith({ + query: 'invoice', + category: PluginCategoryEnum.datasource, + }) + }) + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx b/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx new file mode 100644 index 0000000000..5955665f5e --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx @@ -0,0 +1,197 @@ +import type { TriggerWithProvider } from '../types' +import type { Plugin } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PluginCategoryEnum, SupportedCreationMethods } from '@/app/components/plugins/types' +import { CollectionType } from '@/app/components/tools/types' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import { BlockEnum } from '../../types' +import FeaturedTriggers from '../featured-triggers' + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: vi.fn(), +})) + +vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({ + default: () =>

, +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: () =>
, +})) + +vi.mock('@/utils/var', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMarketplaceUrl: () => 'https://marketplace.test/triggers', + } +}) + +const mockUseTheme = vi.mocked(useTheme) + +const createPlugin = (overrides: Partial = {}): Plugin => ({ + type: 'trigger', + org: 'org', + author: 'author', + name: 'trigger-plugin', + plugin_id: 'plugin-1', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'plugin-1@1.0.0', + icon: 'icon', + verified: true, + label: { en_US: 'Plugin One', zh_Hans: '插件一' }, + brief: { en_US: 'Brief', zh_Hans: '简介' }, + description: { en_US: 'Plugin description', zh_Hans: '插件描述' }, + introduction: 'Intro', + repository: 'https://example.com', + category: PluginCategoryEnum.trigger, + install_count: 12, + endpoint: { settings: [] }, + tags: [{ name: 'tag' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +const createTriggerProvider = (overrides: Partial = {}): TriggerWithProvider => ({ + id: 'provider-1', + name: 'provider-one', + author: 'Provider Author', + description: { en_US: 'desc', zh_Hans: '描述' }, + icon: 'icon', + icon_dark: 'icon-dark', + label: { en_US: 'Provider One', zh_Hans: '提供商一' }, + type: CollectionType.trigger, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + plugin_id: 'plugin-1', + plugin_unique_identifier: 'plugin-1@1.0.0', + meta: { version: '1.0.0' }, + credentials_schema: [], + subscription_constructor: null, + subscription_schema: [], + supported_creation_methods: [SupportedCreationMethods.MANUAL], + events: [ + { + name: 'created', + author: 'Provider Author', + label: { en_US: 'Created', zh_Hans: '创建' }, + description: { en_US: 'Created event', zh_Hans: '创建事件' }, + parameters: [], + labels: [], + output_schema: {}, + }, + ], + ...overrides, +}) + +describe('FeaturedTriggers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType) + }) + + // The section should persist collapse state and allow expanding recommended rows. + describe('Visibility Controls', () => { + it('should persist collapse state in localStorage', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ })) + + expect(screen.queryByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).not.toBeInTheDocument() + expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('workflow_triggers_featured_collapsed', 'true') + }) + + it('should show more and show less across installed providers', async () => { + const user = userEvent.setup() + const providers = Array.from({ length: 6 }).map((_, index) => createTriggerProvider({ + id: `provider-${index}`, + name: `provider-${index}`, + label: { en_US: `Provider ${index}`, zh_Hans: `提供商${index}` }, + plugin_id: `plugin-${index}`, + plugin_unique_identifier: `plugin-${index}@1.0.0`, + })) + const providerMap = new Map(providers.map(provider => [provider.plugin_id!, provider])) + const plugins = providers.map(provider => createPlugin({ + plugin_id: provider.plugin_id!, + latest_package_identifier: provider.plugin_unique_identifier, + })) + + render( + , + ) + + expect(screen.getByText('Provider 4')).toBeInTheDocument() + expect(screen.queryByText('Provider 5')).not.toBeInTheDocument() + + await user.click(screen.getByText('workflow.tabs.showMoreFeatured')) + expect(screen.getByText('Provider 5')).toBeInTheDocument() + + await user.click(screen.getByText('workflow.tabs.showLessFeatured')) + expect(screen.queryByText('Provider 5')).not.toBeInTheDocument() + }) + }) + + // Rendering should cover the empty state link and installed trigger selection. + describe('Rendering and Selection', () => { + it('should render the empty state link when there are no featured plugins', () => { + render( + , + ) + + expect(screen.getByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).toHaveAttribute('href', 'https://marketplace.test/triggers') + }) + + it('should select an installed trigger event from the featured list', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const provider = createTriggerProvider() + + render( + , + ) + + await user.click(screen.getByText('Provider One')) + await user.click(screen.getByText('Created')) + + expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({ + provider_id: 'provider-one', + event_name: 'created', + event_label: 'Created', + })) + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx b/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx new file mode 100644 index 0000000000..91b158344b --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx @@ -0,0 +1,97 @@ +import type { ToolWithProvider } from '../../types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { CollectionType } from '../../../tools/types' +import IndexBar, { + CUSTOM_GROUP_NAME, + DATA_SOURCE_GROUP_NAME, + groupItems, + WORKFLOW_GROUP_NAME, +} from '../index-bar' + +const createToolProvider = (overrides: Partial = {}): ToolWithProvider => ({ + id: 'provider-1', + name: 'Provider 1', + author: 'Author', + description: { en_US: 'desc', zh_Hans: '描述' }, + icon: 'icon', + label: { en_US: 'Alpha', zh_Hans: '甲' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: [], + tools: [], + meta: { version: '1.0.0' }, + ...overrides, +}) + +describe('IndexBar', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Grouping should normalize Chinese initials, custom groups, and hash ordering. + describe('groupItems', () => { + it('should group providers by first letter and move hash to the end', () => { + const items: ToolWithProvider[] = [ + createToolProvider({ + id: 'alpha', + label: { en_US: 'Alpha', zh_Hans: '甲' }, + type: CollectionType.builtIn, + author: 'Builtin', + }), + createToolProvider({ + id: 'custom', + label: { en_US: '1Custom', zh_Hans: '1自定义' }, + type: CollectionType.custom, + author: 'Custom', + }), + createToolProvider({ + id: 'workflow', + label: { en_US: '中文工作流', zh_Hans: '中文工作流' }, + type: CollectionType.workflow, + author: 'Workflow', + }), + createToolProvider({ + id: 'source', + label: { en_US: 'Data Source', zh_Hans: '数据源' }, + type: CollectionType.datasource, + author: 'Data', + }), + ] + + const result = groupItems(items, item => item.label.zh_Hans[0] || item.label.en_US[0] || '') + + expect(result.letters).toEqual(['J', 'S', 'Z', '#']) + expect(result.groups.J.Builtin).toHaveLength(1) + expect(result.groups.Z[WORKFLOW_GROUP_NAME]).toHaveLength(1) + expect(result.groups.S[DATA_SOURCE_GROUP_NAME]).toHaveLength(1) + expect(result.groups['#'][CUSTOM_GROUP_NAME]).toHaveLength(1) + }) + }) + + // Clicking a letter should scroll the matching section into view. + describe('Rendering', () => { + it('should call scrollIntoView for the selected letter', async () => { + const user = userEvent.setup() + const scrollIntoView = vi.fn() + const itemRefs = { + current: { + A: { scrollIntoView } as unknown as HTMLElement, + }, + } + + render( + , + ) + + await user.click(screen.getByText('A')) + + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }) + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx new file mode 100644 index 0000000000..6bb50aeca3 --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx @@ -0,0 +1,80 @@ +import type { CommonNodeType } from '../../types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import useNodes from '@/app/components/workflow/store/workflow/use-nodes' +import { useAvailableNodesMetaData } from '../../../workflow-app/hooks' +import { BlockEnum } from '../../types' +import StartBlocks from '../start-blocks' + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + default: vi.fn(), +})) + +vi.mock('../../../workflow-app/hooks', () => ({ + useAvailableNodesMetaData: vi.fn(), +})) + +const mockUseNodes = vi.mocked(useNodes) +const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData) + +const createNode = (type: BlockEnum) => ({ + data: { type } as Pick, +}) as ReturnType[number] + +const createAvailableNodesMetaData = (): ReturnType => ({ + nodes: [], +} as unknown as ReturnType) + +describe('StartBlocks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseNodes.mockReturnValue([]) + mockUseAvailableNodesMetaData.mockReturnValue(createAvailableNodesMetaData()) + }) + + // Start block selection should respect available types and workflow state. + describe('Filtering and Selection', () => { + it('should render available start blocks and forward selection', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const onContentStateChange = vi.fn() + + render( + , + ) + + expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument() + expect(screen.getByText('workflow.blocks.trigger-webhook')).toBeInTheDocument() + expect(screen.getByText('workflow.blocks.originalStartNode')).toBeInTheDocument() + expect(onContentStateChange).toHaveBeenCalledWith(true) + + await user.click(screen.getByText('workflow.blocks.start')) + + expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start) + }) + + it('should hide user input when a start node already exists or hideUserInput is enabled', () => { + const onContentStateChange = vi.fn() + mockUseNodes.mockReturnValue([createNode(BlockEnum.Start)]) + + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + expect(screen.queryByText('workflow.blocks.start')).not.toBeInTheDocument() + expect(onContentStateChange).toHaveBeenCalledWith(false) + }) + }) +}) diff --git a/web/app/components/workflow/edge-contextmenu.spec.tsx b/web/app/components/workflow/edge-contextmenu.spec.tsx deleted file mode 100644 index c1b021e624..0000000000 --- a/web/app/components/workflow/edge-contextmenu.spec.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useEffect } from 'react' -import { resetReactFlowMockState, rfState } from './__tests__/reactflow-mock-state' -import { renderWorkflowComponent } from './__tests__/workflow-test-env' -import EdgeContextmenu from './edge-contextmenu' -import { useEdgesInteractions } from './hooks/use-edges-interactions' - -vi.mock('reactflow', async () => - (await import('./__tests__/reactflow-mock-state')).createReactFlowModuleMock()) - -const mockSaveStateToHistory = vi.fn() - -vi.mock('./hooks/use-workflow-history', () => ({ - useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }), - WorkflowHistoryEvent: { - EdgeDelete: 'EdgeDelete', - EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch', - EdgeSourceHandleChange: 'EdgeSourceHandleChange', - }, -})) - -vi.mock('./hooks/use-workflow', () => ({ - useNodesReadOnly: () => ({ - getNodesReadOnly: () => false, - }), -})) - -vi.mock('./utils', async (importOriginal) => { - const actual = await importOriginal() - - return { - ...actual, - getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})), - } -}) - -vi.mock('./hooks', async () => { - const { useEdgesInteractions } = await import('./hooks/use-edges-interactions') - const { usePanelInteractions } = await import('./hooks/use-panel-interactions') - - return { - useEdgesInteractions, - usePanelInteractions, - } -}) - -describe('EdgeContextmenu', () => { - const hooksStoreProps = { - doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), - } - type TestNode = typeof rfState.nodes[number] & { - selected?: boolean - data: { - selected?: boolean - _isBundled?: boolean - } - } - type TestEdge = typeof rfState.edges[number] & { - selected?: boolean - } - const createNode = (id: string, selected = false): TestNode => ({ - id, - position: { x: 0, y: 0 }, - data: { selected }, - selected, - }) - const createEdge = (id: string, selected = false): TestEdge => ({ - id, - source: 'n1', - target: 'n2', - data: {}, - selected, - }) - - const EdgeMenuHarness = () => { - const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions() - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Delete' && e.key !== 'Backspace') - return - - e.preventDefault() - handleEdgeDelete() - } - - document.addEventListener('keydown', handleKeyDown) - return () => { - document.removeEventListener('keydown', handleKeyDown) - } - }, [handleEdgeDelete]) - - return ( -
- - - -
- ) - } - - beforeEach(() => { - vi.clearAllMocks() - resetReactFlowMockState() - rfState.nodes = [ - createNode('n1'), - createNode('n2'), - ] - rfState.edges = [ - createEdge('e1', true) as typeof rfState.edges[number] & { selected: boolean }, - createEdge('e2'), - ] - rfState.setNodes.mockImplementation((nextNodes) => { - rfState.nodes = nextNodes as typeof rfState.nodes - }) - rfState.setEdges.mockImplementation((nextEdges) => { - rfState.edges = nextEdges as typeof rfState.edges - }) - }) - - it('should not render when edgeMenu is absent', () => { - renderWorkflowComponent(, { - hooksStoreProps, - }) - - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) - - it('should delete the menu edge and close the menu when another edge is selected', async () => { - const user = userEvent.setup() - ;(rfState.edges[0] as Record).selected = true - ;(rfState.edges[1] as Record).selected = false - - const { store } = renderWorkflowComponent(, { - initialStoreState: { - edgeMenu: { - clientX: 320, - clientY: 180, - edgeId: 'e2', - }, - }, - hooksStoreProps, - }) - - const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i }) - expect(screen.getByText(/^del$/i)).toBeInTheDocument() - - await user.click(deleteAction) - - const updatedEdges = rfState.setEdges.mock.calls.at(-1)?.[0] - expect(updatedEdges).toHaveLength(1) - expect(updatedEdges[0].id).toBe('e1') - expect(updatedEdges[0].selected).toBe(true) - expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') - - await waitFor(() => { - expect(store.getState().edgeMenu).toBeUndefined() - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) - }) - - it('should not render the menu when the referenced edge no longer exists', () => { - renderWorkflowComponent(, { - initialStoreState: { - edgeMenu: { - clientX: 320, - clientY: 180, - edgeId: 'missing-edge', - }, - }, - hooksStoreProps, - }) - - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) - - it('should open the edge menu at the right-click position', async () => { - const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') - - renderWorkflowComponent(, { - hooksStoreProps, - }) - - fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { - clientX: 320, - clientY: 180, - }) - - expect(await screen.findByRole('menu')).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument() - expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - x: 320, - y: 180, - width: 0, - height: 0, - })) - }) - - it('should delete the right-clicked edge and close the menu when delete is clicked', async () => { - const user = userEvent.setup() - - renderWorkflowComponent(, { - hooksStoreProps, - }) - - fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { - clientX: 320, - clientY: 180, - }) - - await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i })) - - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) - expect(rfState.edges.map(edge => edge.id)).toEqual(['e1']) - expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') - }) - - it.each([ - ['Delete', 'Delete'], - ['Backspace', 'Backspace'], - ])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => { - renderWorkflowComponent(, { - hooksStoreProps, - }) - rfState.nodes = [createNode('n1', true), createNode('n2')] - - fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { - clientX: 240, - clientY: 120, - }) - - expect(await screen.findByRole('menu')).toBeInTheDocument() - - fireEvent.keyDown(document, { key }) - - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) - expect(rfState.edges.map(edge => edge.id)).toEqual(['e1']) - expect(rfState.nodes.map(node => node.id)).toEqual(['n1', 'n2']) - expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected)).toBe(true) - }) - - it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => { - renderWorkflowComponent(, { - hooksStoreProps, - }) - rfState.nodes = [ - { ...createNode('n1', true), data: { selected: true, _isBundled: true } }, - { ...createNode('n2', true), data: { selected: true, _isBundled: true } }, - ] - - fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), { - clientX: 200, - clientY: 100, - }) - - expect(await screen.findByRole('menu')).toBeInTheDocument() - - fireEvent.keyDown(document, { key: 'Delete' }) - - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) - expect(rfState.edges.map(edge => edge.id)).toEqual(['e2']) - expect(rfState.nodes).toHaveLength(2) - expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected && !node.data._isBundled)).toBe(true) - }) - - it('should retarget the menu and selected edge when right-clicking a different edge', async () => { - const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') - - renderWorkflowComponent(, { - hooksStoreProps, - }) - const edgeOneButton = screen.getByLabelText('Right-click edge e1') - const edgeTwoButton = screen.getByLabelText('Right-click edge e2') - - fireEvent.contextMenu(edgeOneButton, { - clientX: 80, - clientY: 60, - }) - expect(await screen.findByRole('menu')).toBeInTheDocument() - - fireEvent.contextMenu(edgeTwoButton, { - clientX: 360, - clientY: 240, - }) - - await waitFor(() => { - expect(screen.getAllByRole('menu')).toHaveLength(1) - expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - x: 360, - y: 240, - })) - expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e1')?.selected).toBe(false) - expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e2')?.selected).toBe(true) - }) - }) - - it('should hide the menu when the target edge disappears after opening it', async () => { - const { store } = renderWorkflowComponent(, { - hooksStoreProps, - }) - - fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), { - clientX: 160, - clientY: 100, - }) - expect(await screen.findByRole('menu')).toBeInTheDocument() - - rfState.edges = [createEdge('e2')] - store.setState({ - edgeMenu: { - clientX: 160, - clientY: 100, - edgeId: 'e1', - }, - }) - - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/workflow/header/run-mode.spec.tsx b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx similarity index 94% rename from web/app/components/workflow/header/run-mode.spec.tsx rename to web/app/components/workflow/header/__tests__/run-mode.spec.tsx index 2f44d4a21b..cb5214544a 100644 --- a/web/app/components/workflow/header/run-mode.spec.tsx +++ b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx @@ -2,8 +2,8 @@ import type { ReactNode } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { WorkflowRunningStatus } from '@/app/components/workflow/types' -import RunMode from './run-mode' -import { TriggerType } from './test-run-menu' +import RunMode from '../run-mode' +import { TriggerType } from '../test-run-menu' const mockHandleWorkflowStartRunInWorkflow = vi.fn() const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn() @@ -42,7 +42,7 @@ vi.mock('@/app/components/workflow/store', () => ({ selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }), })) -vi.mock('../hooks/use-dynamic-test-run-options', () => ({ +vi.mock('../../hooks/use-dynamic-test-run-options', () => ({ useDynamicTestRunOptions: () => mockDynamicOptions, })) @@ -72,8 +72,8 @@ vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({ StopCircle: () => , })) -vi.mock('./test-run-menu', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../test-run-menu', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => { diff --git a/web/app/components/workflow/header/checklist/index.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/workflow/header/checklist/index.spec.tsx rename to web/app/components/workflow/header/checklist/__tests__/index.spec.tsx index 6a31bd6a74..2c83747dc0 100644 --- a/web/app/components/workflow/header/checklist/index.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { fireEvent, render, screen } from '@testing-library/react' -import { BlockEnum } from '../../types' -import WorkflowChecklist from './index' +import { BlockEnum } from '../../../types' +import WorkflowChecklist from '../index' let mockChecklistItems = [ { @@ -40,7 +40,7 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ default: () => [], })) -vi.mock('../../hooks', () => ({ +vi.mock('../../../hooks', () => ({ useChecklist: () => mockChecklistItems, useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect, @@ -57,11 +57,11 @@ vi.mock('@/app/components/base/ui/popover', () => ({ PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => , })) -vi.mock('./plugin-group', () => ({ +vi.mock('../plugin-group', () => ({ ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) =>
{items.map(item => item.title).join(',')}
, })) -vi.mock('./node-group', () => ({ +vi.mock('../node-group', () => ({ ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => ( diff --git a/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f66c5f0473 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx @@ -0,0 +1,83 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Collapse from '../index' + +describe('Collapse', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Collapse should toggle local state when interactive and stay fixed when disabled. + describe('Interaction', () => { + it('should expand collapsed content and notify onCollapse when clicked', async () => { + const user = userEvent.setup() + const onCollapse = vi.fn() + + render( + Advanced
} + onCollapse={onCollapse} + > +
Collapse content
+ , + ) + + expect(screen.queryByText('Collapse content')).not.toBeInTheDocument() + + await user.click(screen.getByText('Advanced')) + + expect(screen.getByText('Collapse content')).toBeInTheDocument() + expect(onCollapse).toHaveBeenCalledWith(false) + }) + + it('should keep content collapsed when disabled', async () => { + const user = userEvent.setup() + const onCollapse = vi.fn() + + render( + Disabled section
} + onCollapse={onCollapse} + > +
Hidden content
+ , + ) + + await user.click(screen.getByText('Disabled section')) + + expect(screen.queryByText('Hidden content')).not.toBeInTheDocument() + expect(onCollapse).not.toHaveBeenCalled() + }) + + it('should respect controlled collapse state and render function triggers', async () => { + const user = userEvent.setup() + const onCollapse = vi.fn() + + render( + Operation} + trigger={collapseIcon => ( +
+ Controlled section + {collapseIcon} +
+ )} + onCollapse={onCollapse} + > +
Visible content
+
, + ) + + expect(screen.getByText('Visible content')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Operation' })).toBeInTheDocument() + + await user.click(screen.getByText('Controlled section')) + + expect(onCollapse).toHaveBeenCalledWith(true) + expect(screen.getByText('Visible content')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx new file mode 100644 index 0000000000..a6d6d0bf6c --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react' +import InputField from '../index' + +describe('InputField', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The placeholder field should render its title, body, and add action. + describe('Rendering', () => { + it('should render the default field title and content', () => { + render() + + expect(screen.getAllByText('input field')).toHaveLength(2) + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/layout/field-title.spec.tsx b/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx similarity index 98% rename from web/app/components/workflow/nodes/_base/components/layout/field-title.spec.tsx rename to web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx index 3b1be0040e..8eec97111a 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/field-title.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/__tests__/field-title.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { FieldTitle } from './field-title' +import { FieldTitle } from '../field-title' vi.mock('@/app/components/base/ui/tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, diff --git a/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx new file mode 100644 index 0000000000..680965eb06 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react' +import { BoxGroupField, FieldTitle } from '../index' + +describe('layout index', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The barrel exports should compose the public layout primitives without extra wrappers. + describe('Rendering', () => { + it('should render BoxGroupField from the barrel export', () => { + render( + + Body content + , + ) + + expect(screen.getByText('Input')).toBeInTheDocument() + expect(screen.getByText('Body content')).toBeInTheDocument() + }) + + it('should render FieldTitle from the barrel export', () => { + render() + + expect(screen.getByText('Advanced')).toBeInTheDocument() + expect(screen.getByText('Extra details')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx new file mode 100644 index 0000000000..82b2ee9603 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx @@ -0,0 +1,195 @@ +import type { ReactNode } from 'react' +import type { Edge, Node } from '@/app/components/workflow/types' +import { screen } from '@testing-library/react' +import { + createEdge, + createNode, +} from '@/app/components/workflow/__tests__/fixtures' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { + useAvailableBlocks, + useNodesInteractions, + useNodesReadOnly, + useToolIcon, +} from '@/app/components/workflow/hooks' +import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' +import { BlockEnum } from '@/app/components/workflow/types' +import NextStep from '../index' + +vi.mock('@/app/components/workflow/block-selector', () => ({ + default: ({ trigger }: { trigger: ((open: boolean) => ReactNode) | ReactNode }) => { + return ( +
+ {typeof trigger === 'function' ? trigger(false) : trigger} +
+ ) + }, +})) + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAvailableBlocks: vi.fn(), + useNodesInteractions: vi.fn(), + useNodesReadOnly: vi.fn(), + useToolIcon: vi.fn(), + } +}) + +const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks) +const mockUseNodesInteractions = vi.mocked(useNodesInteractions) +const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) +const mockUseToolIcon = vi.mocked(useToolIcon) + +const createAvailableBlocksResult = (): ReturnType => ({ + getAvailableBlocks: vi.fn(() => ({ + availablePrevBlocks: [], + availableNextBlocks: [], + })), + availablePrevBlocks: [], + availableNextBlocks: [], +}) + +const renderComponent = (selectedNode: Node, nodes: Node[], edges: Edge[] = []) => + renderWorkflowFlowComponent( + , + { + nodes, + edges, + canvasStyle: { + width: 600, + height: 400, + }, + }, + ) + +describe('NextStep', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult()) + mockUseNodesInteractions.mockReturnValue({ + handleNodeSelect: vi.fn(), + handleNodeAdd: vi.fn(), + } as unknown as ReturnType) + mockUseNodesReadOnly.mockReturnValue({ + nodesReadOnly: true, + } as ReturnType) + mockUseToolIcon.mockReturnValue('') + }) + + // NextStep should summarize linear next nodes and failure branches from the real ReactFlow graph. + describe('Rendering', () => { + it('should render connected next nodes and the parallel add action for the default source handle', () => { + const selectedNode = createNode({ + id: 'selected-node', + data: { + type: BlockEnum.Code, + title: 'Selected Node', + }, + }) + const nextNode = createNode({ + id: 'next-node', + data: { + type: BlockEnum.Answer, + title: 'Next Node', + }, + }) + const edge = createEdge({ + source: 'selected-node', + target: 'next-node', + sourceHandle: 'source', + }) + + renderComponent(selectedNode, [selectedNode, nextNode], [edge]) + + expect(screen.getByText('Next Node')).toBeInTheDocument() + expect(screen.getByText('workflow.common.addParallelNode')).toBeInTheDocument() + }) + + it('should render configured branch names when target branches are present', () => { + const selectedNode = createNode({ + id: 'selected-node', + data: { + type: BlockEnum.Code, + title: 'Selected Node', + _targetBranches: [{ + id: 'branch-a', + name: 'Approved', + }], + }, + }) + const nextNode = createNode({ + id: 'next-node', + data: { + type: BlockEnum.Answer, + title: 'Branch Node', + }, + }) + const edge = createEdge({ + source: 'selected-node', + target: 'next-node', + sourceHandle: 'branch-a', + }) + + renderComponent(selectedNode, [selectedNode, nextNode], [edge]) + + expect(screen.getByText('Approved')).toBeInTheDocument() + expect(screen.getByText('Branch Node')).toBeInTheDocument() + expect(screen.getByText('workflow.common.addParallelNode')).toBeInTheDocument() + }) + + it('should number question-classifier branches even when no target node is connected', () => { + const selectedNode = createNode({ + id: 'selected-node', + data: { + type: BlockEnum.QuestionClassifier, + title: 'Classifier', + _targetBranches: [{ + id: 'branch-b', + name: 'Original branch name', + }], + }, + }) + const danglingEdge = createEdge({ + source: 'selected-node', + target: 'missing-node', + sourceHandle: 'branch-b', + }) + + renderComponent(selectedNode, [selectedNode], [danglingEdge]) + + expect(screen.getByText('workflow.nodes.questionClassifiers.class 1')).toBeInTheDocument() + expect(screen.getByText('workflow.panel.selectNextStep')).toBeInTheDocument() + }) + + it('should render the failure branch when the node has error handling enabled', () => { + const selectedNode = createNode({ + id: 'selected-node', + data: { + type: BlockEnum.Code, + title: 'Selected Node', + error_strategy: ErrorHandleTypeEnum.failBranch, + }, + }) + const failNode = createNode({ + id: 'fail-node', + data: { + type: BlockEnum.Answer, + title: 'Failure Node', + }, + }) + const failEdge = createEdge({ + source: 'selected-node', + target: 'fail-node', + sourceHandle: ErrorHandleTypeEnum.failBranch, + }) + + renderComponent(selectedNode, [selectedNode, failNode], [failEdge]) + + expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument() + expect(screen.getByText('Failure Node')).toBeInTheDocument() + expect(screen.getByText('workflow.common.addFailureBranch')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx new file mode 100644 index 0000000000..183e28c5f0 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx @@ -0,0 +1,162 @@ +import type { UseQueryResult } from '@tanstack/react-query' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { + useNodeDataUpdate, + useNodeMetaData, + useNodesInteractions, + useNodesReadOnly, + useNodesSyncDraft, +} from '@/app/components/workflow/hooks' +import { BlockEnum } from '@/app/components/workflow/types' +import { useAllWorkflowTools } from '@/service/use-tools' +import PanelOperator from '../index' + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNodeDataUpdate: vi.fn(), + useNodeMetaData: vi.fn(), + useNodesInteractions: vi.fn(), + useNodesReadOnly: vi.fn(), + useNodesSyncDraft: vi.fn(), + } +}) + +vi.mock('@/service/use-tools', () => ({ + useAllWorkflowTools: vi.fn(), +})) + +vi.mock('../change-block', () => ({ + default: () =>
, +})) + +const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate) +const mockUseNodeMetaData = vi.mocked(useNodeMetaData) +const mockUseNodesInteractions = vi.mocked(useNodesInteractions) +const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) +const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft) +const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) + +const createQueryResult = (data: T): UseQueryResult => ({ + data, + error: null, + refetch: vi.fn(), + isError: false, + isPending: false, + isLoading: false, + isSuccess: true, + isFetching: false, + isRefetching: false, + isLoadingError: false, + isRefetchError: false, + isInitialLoading: false, + isPaused: false, + isEnabled: true, + status: 'success', + fetchStatus: 'idle', + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + isStale: false, + promise: Promise.resolve(data), +} as UseQueryResult) + +const renderComponent = (showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void) => + renderWorkflowFlowComponent( + , + { + nodes: [], + edges: [], + }, + ) + +describe('PanelOperator', () => { + const handleNodeSelect = vi.fn() + const handleNodeDataUpdate = vi.fn() + const handleSyncWorkflowDraft = vi.fn() + const handleNodeDelete = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockUseNodeDataUpdate.mockReturnValue({ + handleNodeDataUpdate, + handleNodeDataUpdateWithSyncDraft: vi.fn(), + }) + mockUseNodeMetaData.mockReturnValue({ + isTypeFixed: false, + isSingleton: false, + isUndeletable: false, + description: 'Node description', + author: 'Dify', + helpLinkUri: 'https://docs.example.com/node', + } as ReturnType) + mockUseNodesInteractions.mockReturnValue({ + handleNodeDelete, + handleNodesDuplicate: vi.fn(), + handleNodeSelect, + handleNodesCopy: vi.fn(), + } as unknown as ReturnType) + mockUseNodesReadOnly.mockReturnValue({ + nodesReadOnly: false, + } as ReturnType) + mockUseNodesSyncDraft.mockReturnValue({ + doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), + handleSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose: vi.fn(), + }) + mockUseAllWorkflowTools.mockReturnValue(createQueryResult([])) + }) + + // The operator should open the real popup, expose actionable items, and respect help-link visibility. + describe('Popup Interaction', () => { + it('should open the popup and trigger single-run actions', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const { container } = renderComponent(true, onOpenChange) + + await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement) + + expect(onOpenChange).toHaveBeenCalledWith(true) + expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument() + expect(screen.getByText('Node description')).toBeInTheDocument() + + await user.click(screen.getByText('workflow.panel.runThisStep')) + + expect(handleNodeSelect).toHaveBeenCalledWith('node-1') + expect(handleNodeDataUpdate).toHaveBeenCalledWith({ + id: 'node-1', + data: { _isSingleRun: true }, + }) + expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true) + }) + + it('should hide the help link when showHelpLink is false', async () => { + const user = userEvent.setup() + const { container } = renderComponent(false) + + await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement) + + expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument() + expect(screen.getByText('Node description')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/variable/match-schema-type.spec.ts b/web/app/components/workflow/nodes/_base/components/variable/__tests__/match-schema-type.spec.ts similarity index 98% rename from web/app/components/workflow/nodes/_base/components/variable/match-schema-type.spec.ts rename to web/app/components/workflow/nodes/_base/components/variable/__tests__/match-schema-type.spec.ts index ef7a24faf5..0330ae47fc 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/match-schema-type.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/match-schema-type.spec.ts @@ -1,4 +1,4 @@ -import matchTheSchemaType from './match-schema-type' +import matchTheSchemaType from '../match-schema-type' describe('match the schema type', () => { it('should return true for identical primitive types', () => { diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx new file mode 100644 index 0000000000..cb44e93427 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { VariableLabelInNode, VariableLabelInText } from '../index' + +describe('variable-label index', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The barrel exports should render the node and text variants with the expected variable metadata. + describe('Rendering', () => { + it('should render the node variant with node label and variable type', () => { + render( + , + ) + + expect(screen.getByText('Source Node')).toBeInTheDocument() + expect(screen.getByText('answer')).toBeInTheDocument() + expect(screen.getByText('String')).toBeInTheDocument() + }) + + it('should render the text variant with the shortened variable path', () => { + render( + , + ) + + expect(screen.getByTestId('exception-variable')).toBeInTheDocument() + expect(screen.getByText('Source Node')).toBeInTheDocument() + expect(screen.getByText('answer')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/workflow/nodes/_base/components/workflow-panel/index.spec.tsx rename to web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx diff --git a/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx new file mode 100644 index 0000000000..38a8b88c81 --- /dev/null +++ b/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx @@ -0,0 +1,67 @@ +import type { AnswerNodeType } from '../types' +import { screen } from '@testing-library/react' +import { createNode } from '@/app/components/workflow/__tests__/fixtures' +import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { useWorkflow } from '@/app/components/workflow/hooks' +import { BlockEnum } from '@/app/components/workflow/types' +import Node from '../node' + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useWorkflow: vi.fn(), + } +}) + +const mockUseWorkflow = vi.mocked(useWorkflow) + +const createNodeData = (overrides: Partial = {}): AnswerNodeType => ({ + title: 'Answer', + desc: '', + type: BlockEnum.Answer, + variables: [], + answer: 'Plain answer', + ...overrides, +}) + +describe('AnswerNode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseWorkflow.mockReturnValue({ + getBeforeNodesInSameBranchIncludeParent: () => [], + } as unknown as ReturnType) + }) + + // The node should render the localized panel title and plain answer text. + describe('Rendering', () => { + it('should render the answer title and text content', () => { + renderNodeComponent(Node, createNodeData()) + + expect(screen.getByText('workflow.nodes.answer.answer')).toBeInTheDocument() + expect(screen.getByText('Plain answer')).toBeInTheDocument() + }) + + it('should render referenced variables inside the readonly content', () => { + mockUseWorkflow.mockReturnValue({ + getBeforeNodesInSameBranchIncludeParent: () => [ + createNode({ + id: 'source-node', + data: { + type: BlockEnum.Code, + title: 'Source Node', + }, + }), + ], + } as unknown as ReturnType) + + renderNodeComponent(Node, createNodeData({ + answer: 'Hello {{#source-node.name#}}', + })) + + expect(screen.getByText('Hello')).toBeInTheDocument() + expect(screen.getByText('Source Node')).toBeInTheDocument() + expect(screen.getByText('name')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/code/code-parser.spec.ts b/web/app/components/workflow/nodes/code/__tests__/code-parser.spec.ts similarity index 98% rename from web/app/components/workflow/nodes/code/code-parser.spec.ts rename to web/app/components/workflow/nodes/code/__tests__/code-parser.spec.ts index d7fd590f28..ea2d7f49ef 100644 --- a/web/app/components/workflow/nodes/code/code-parser.spec.ts +++ b/web/app/components/workflow/nodes/code/__tests__/code-parser.spec.ts @@ -1,6 +1,6 @@ -import { VarType } from '../../types' -import { extractFunctionParams, extractReturnType } from './code-parser' -import { CodeLanguage } from './types' +import { VarType } from '../../../types' +import { extractFunctionParams, extractReturnType } from '../code-parser' +import { CodeLanguage } from '../types' const SAMPLE_CODES = { python3: { diff --git a/web/app/components/workflow/nodes/data-source-empty/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/data-source-empty/__tests__/index.spec.tsx new file mode 100644 index 0000000000..48e679813d --- /dev/null +++ b/web/app/components/workflow/nodes/data-source-empty/__tests__/index.spec.tsx @@ -0,0 +1,101 @@ +import type { ComponentProps, ReactNode } from 'react' +import type { OnSelectBlock } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum } from '@/app/components/workflow/types' +import DataSourceEmptyNode from '../index' + +const mockUseReplaceDataSourceNode = vi.hoisted(() => vi.fn()) + +vi.mock('../hooks', () => ({ + useReplaceDataSourceNode: mockUseReplaceDataSourceNode, +})) + +vi.mock('@/app/components/workflow/block-selector', () => ({ + default: ({ + onSelect, + trigger, + }: { + onSelect: OnSelectBlock + trigger: ((open?: boolean) => ReactNode) | ReactNode + }) => ( +
+ {typeof trigger === 'function' ? trigger(false) : trigger} + +
+ ), +})) + +type DataSourceEmptyNodeProps = ComponentProps + +const createNodeProps = (): DataSourceEmptyNodeProps => ({ + id: 'data-source-empty-node', + data: { + width: 240, + height: 88, + }, + type: 'default', + selected: false, + zIndex: 0, + isConnectable: true, + xPos: 0, + yPos: 0, + dragging: false, + dragHandle: undefined, +} as unknown as DataSourceEmptyNodeProps) + +describe('DataSourceEmptyNode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseReplaceDataSourceNode.mockReturnValue({ + handleReplaceNode: vi.fn(), + }) + }) + + // The empty datasource node should render the add trigger and forward selector choices. + describe('Rendering and Selection', () => { + it('should render the datasource add trigger', () => { + render( + , + ) + + expect(screen.getByText('workflow.nodes.dataSource.add')).toBeInTheDocument() + expect(screen.getByText('workflow.blocks.datasource')).toBeInTheDocument() + }) + + it('should forward block selections to the replace hook', async () => { + const user = userEvent.setup() + const handleReplaceNode = vi.fn() + mockUseReplaceDataSourceNode.mockReturnValue({ + handleReplaceNode, + }) + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'select data source' })) + + expect(handleReplaceNode).toHaveBeenCalledWith(BlockEnum.DataSource, { + plugin_id: 'plugin-id', + provider_type: 'datasource', + provider_name: 'file', + datasource_name: 'local-file', + datasource_label: 'Local File', + title: 'Local File', + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx new file mode 100644 index 0000000000..686e145ef3 --- /dev/null +++ b/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx @@ -0,0 +1,76 @@ +import type { DataSourceNodeType } from '../types' +import { render, screen } from '@testing-library/react' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' +import { BlockEnum } from '@/app/components/workflow/types' +import Node from '../node' + +const mockInstallPluginButton = vi.hoisted(() => vi.fn(({ uniqueIdentifier }: { uniqueIdentifier: string }) => ( + +))) + +vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({ + useNodePluginInstallation: vi.fn(), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ + InstallPluginButton: mockInstallPluginButton, +})) + +const mockUseNodePluginInstallation = vi.mocked(useNodePluginInstallation) + +const createNodeData = (overrides: Partial = {}): DataSourceNodeType => ({ + title: 'Datasource', + desc: '', + type: BlockEnum.DataSource, + plugin_id: 'plugin-id', + provider_type: 'datasource', + provider_name: 'file', + datasource_name: 'local-file', + datasource_label: 'Local File', + datasource_parameters: {}, + datasource_configurations: {}, + plugin_unique_identifier: 'plugin-id@1.0.0', + ...overrides, +}) + +describe('DataSourceNode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseNodePluginInstallation.mockReturnValue({ + isChecking: false, + isMissing: false, + uniqueIdentifier: undefined, + canInstall: false, + onInstallSuccess: vi.fn(), + shouldDim: false, + }) + }) + + // The node should only expose install affordances when the backing plugin is missing and installable. + describe('Plugin Installation', () => { + it('should render the install button when the datasource plugin is missing', () => { + mockUseNodePluginInstallation.mockReturnValue({ + isChecking: false, + isMissing: true, + uniqueIdentifier: 'plugin-id@1.0.0', + canInstall: true, + onInstallSuccess: vi.fn(), + shouldDim: true, + }) + + render() + + expect(screen.getByRole('button', { name: 'plugin-id@1.0.0' })).toBeInTheDocument() + expect(mockInstallPluginButton).toHaveBeenCalledWith(expect.objectContaining({ + uniqueIdentifier: 'plugin-id@1.0.0', + extraIdentifiers: ['plugin-id', 'file'], + }), undefined) + }) + + it('should render nothing when installation is unavailable', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx new file mode 100644 index 0000000000..de5e819267 --- /dev/null +++ b/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx @@ -0,0 +1,93 @@ +import type { EndNodeType } from '../types' +import { screen } from '@testing-library/react' +import { createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures' +import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { + useIsChatMode, + useWorkflow, + useWorkflowVariables, +} from '@/app/components/workflow/hooks' +import { BlockEnum } from '@/app/components/workflow/types' +import Node from '../node' + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useWorkflow: vi.fn(), + useWorkflowVariables: vi.fn(), + useIsChatMode: vi.fn(), + } +}) + +const mockUseWorkflow = vi.mocked(useWorkflow) +const mockUseWorkflowVariables = vi.mocked(useWorkflowVariables) +const mockUseIsChatMode = vi.mocked(useIsChatMode) + +const createNodeData = (overrides: Partial = {}): EndNodeType => ({ + title: 'End', + desc: '', + type: BlockEnum.End, + outputs: [{ + variable: 'answer', + value_selector: ['source-node', 'answer'], + }], + ...overrides, +}) + +describe('EndNode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseWorkflow.mockReturnValue({ + getBeforeNodesInSameBranch: () => [ + createStartNode(), + createNode({ + id: 'source-node', + data: { + type: BlockEnum.Code, + title: 'Source Node', + }, + }), + ], + } as unknown as ReturnType) + mockUseWorkflowVariables.mockReturnValue({ + getNodeAvailableVars: () => [], + getCurrentVariableType: () => 'string', + } as unknown as ReturnType) + mockUseIsChatMode.mockReturnValue(false) + }) + + // The node should surface only resolved outputs and ignore empty selectors. + describe('Rendering', () => { + it('should render resolved output labels for referenced nodes', () => { + renderNodeComponent(Node, createNodeData()) + + expect(screen.getByText('Source Node')).toBeInTheDocument() + expect(screen.getByText('answer')).toBeInTheDocument() + expect(screen.getByText('String')).toBeInTheDocument() + }) + + it('should fall back to the start node when the selector node cannot be found', () => { + renderNodeComponent(Node, createNodeData({ + outputs: [{ + variable: 'answer', + value_selector: ['missing-node', 'answer'], + }], + })) + + expect(screen.getByText('Start')).toBeInTheDocument() + expect(screen.getByText('answer')).toBeInTheDocument() + }) + + it('should render nothing when every output selector is empty', () => { + const { container } = renderNodeComponent(Node, createNodeData({ + outputs: [{ + variable: 'answer', + value_selector: [], + }], + })) + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx new file mode 100644 index 0000000000..61d37cbec1 --- /dev/null +++ b/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx @@ -0,0 +1,94 @@ +import type { NodeProps } from 'reactflow' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { render, waitFor } from '@testing-library/react' +import { createNode } from '@/app/components/workflow/__tests__/fixtures' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { + useAvailableBlocks, + useIsChatMode, + useNodesInteractions, + useNodesReadOnly, +} from '@/app/components/workflow/hooks' +import { BlockEnum } from '@/app/components/workflow/types' +import IterationStartNode, { IterationStartNodeDumb } from '../index' + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAvailableBlocks: vi.fn(), + useNodesInteractions: vi.fn(), + useNodesReadOnly: vi.fn(), + useIsChatMode: vi.fn(), + } +}) + +const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks) +const mockUseNodesInteractions = vi.mocked(useNodesInteractions) +const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) +const mockUseIsChatMode = vi.mocked(useIsChatMode) + +const createAvailableBlocksResult = (): ReturnType => ({ + getAvailableBlocks: vi.fn(() => ({ + availablePrevBlocks: [], + availableNextBlocks: [], + })), + availablePrevBlocks: [], + availableNextBlocks: [], +}) + +const FlowNode = (props: NodeProps) => ( + +) + +const renderFlowNode = () => + renderWorkflowFlowComponent(
, { + nodes: [createNode({ + id: 'iteration-start-node', + type: 'iterationStartNode', + data: { + title: 'Iteration Start', + desc: '', + type: BlockEnum.IterationStart, + }, + })], + edges: [], + reactFlowProps: { + nodeTypes: { iterationStartNode: FlowNode }, + }, + canvasStyle: { + width: 400, + height: 300, + }, + }) + +describe('IterationStartNode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult()) + mockUseNodesInteractions.mockReturnValue({ + handleNodeAdd: vi.fn(), + } as unknown as ReturnType) + mockUseNodesReadOnly.mockReturnValue({ + getNodesReadOnly: () => false, + } as unknown as ReturnType) + mockUseIsChatMode.mockReturnValue(false) + }) + + // The start marker should provide the source handle in flow mode and omit it in dumb mode. + describe('Rendering', () => { + it('should render the source handle in the ReactFlow context', async () => { + const { container } = renderFlowNode() + + await waitFor(() => { + expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument() + }) + }) + + it('should render the dumb variant without any source handle', () => { + const { container } = render() + + expect(container.querySelector('[data-handleid="source"]')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/default.spec.ts b/web/app/components/workflow/nodes/knowledge-base/__tests__/default.spec.ts similarity index 95% rename from web/app/components/workflow/nodes/knowledge-base/default.spec.ts rename to web/app/components/workflow/nodes/knowledge-base/__tests__/default.spec.ts index becc6cb9d8..7b2ad9268e 100644 --- a/web/app/components/workflow/nodes/knowledge-base/default.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/default.spec.ts @@ -1,12 +1,12 @@ -import type { KnowledgeBaseNodeType } from './types' +import type { KnowledgeBaseNodeType } from '../types' import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' -import nodeDefault from './default' -import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types' +import nodeDefault from '../default' +import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types' const t = (key: string) => key diff --git a/web/app/components/workflow/nodes/knowledge-base/node.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/__tests__/node.spec.tsx similarity index 97% rename from web/app/components/workflow/nodes/knowledge-base/node.spec.tsx rename to web/app/components/workflow/nodes/knowledge-base/__tests__/node.spec.tsx index 19cf6a0626..5ce60ca959 100644 --- a/web/app/components/workflow/nodes/knowledge-base/node.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/node.spec.tsx @@ -1,4 +1,4 @@ -import type { KnowledgeBaseNodeType } from './types' +import type { KnowledgeBaseNodeType } from '../types' import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { CommonNodeType } from '@/app/components/workflow/types' import { render, screen } from '@testing-library/react' @@ -8,12 +8,12 @@ import { ModelTypeEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { BlockEnum } from '@/app/components/workflow/types' -import Node from './node' +import Node from '../node' import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum, -} from './types' +} from '../types' const mockUseModelList = vi.hoisted(() => vi.fn()) const mockUseSettingsDisplay = vi.hoisted(() => vi.fn()) @@ -36,11 +36,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', asy } }) -vi.mock('./hooks/use-settings-display', () => ({ +vi.mock('../hooks/use-settings-display', () => ({ useSettingsDisplay: mockUseSettingsDisplay, })) -vi.mock('./hooks/use-embedding-model-status', () => ({ +vi.mock('../hooks/use-embedding-model-status', () => ({ useEmbeddingModelStatus: mockUseEmbeddingModelStatus, })) diff --git a/web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx similarity index 94% rename from web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx rename to web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx index 2f76449b6c..0a15845445 100644 --- a/web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx @@ -2,8 +2,8 @@ import type { ReactNode } from 'react' import type { PanelProps } from '@/types/workflow' import { render, screen } from '@testing-library/react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import Panel from './panel' -import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types' +import Panel from '../panel' +import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types' const mockUseModelList = vi.hoisted(() => vi.fn()) const mockUseQuery = vi.hoisted(() => vi.fn()) @@ -35,7 +35,7 @@ vi.mock('@/app/components/workflow/hooks', () => ({ useNodesReadOnly: () => ({ nodesReadOnly: false }), })) -vi.mock('./hooks/use-config', () => ({ +vi.mock('../hooks/use-config', () => ({ useConfig: () => ({ handleChunkStructureChange: vi.fn(), handleIndexMethodChange: vi.fn(), @@ -54,7 +54,7 @@ vi.mock('./hooks/use-config', () => ({ }), })) -vi.mock('./hooks/use-embedding-model-status', () => ({ +vi.mock('../hooks/use-embedding-model-status', () => ({ useEmbeddingModelStatus: mockUseEmbeddingModelStatus, })) @@ -92,19 +92,19 @@ vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({ default: mockSummaryIndexSetting, })) -vi.mock('./components/chunk-structure', () => ({ +vi.mock('../components/chunk-structure', () => ({ default: mockChunkStructure, })) -vi.mock('./components/index-method', () => ({ +vi.mock('../components/index-method', () => ({ default: () =>
, })) -vi.mock('./components/embedding-model', () => ({ +vi.mock('../components/embedding-model', () => ({ default: mockEmbeddingModel, })) -vi.mock('./components/retrieval-setting', () => ({ +vi.mock('../components/retrieval-setting', () => ({ default: () =>
, })) diff --git a/web/app/components/workflow/nodes/knowledge-base/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/knowledge-base/__tests__/use-single-run-form-params.spec.ts new file mode 100644 index 0000000000..ce0216b275 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/use-single-run-form-params.spec.ts @@ -0,0 +1,93 @@ +import type { KnowledgeBaseNodeType } from '../types' +import { act, renderHook } from '@testing-library/react' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' +import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types' +import useSingleRunFormParams from '../use-single-run-form-params' + +const createPayload = (overrides: Partial = {}): KnowledgeBaseNodeType => ({ + title: 'Knowledge Base', + desc: '', + type: BlockEnum.KnowledgeBase, + index_chunk_variable_selector: ['chunks', 'results'], + chunk_structure: ChunkStructureEnum.general, + indexing_technique: IndexMethodEnum.QUALIFIED, + embedding_model: 'text-embedding-3-large', + embedding_model_provider: 'openai', + keyword_number: 10, + retrieval_model: { + search_method: RetrievalSearchMethodEnum.semantic, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + ...overrides, +}) + +describe('useSingleRunFormParams', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The hook should expose the single query form and map chunk dependencies for single-run execution. + describe('Forms', () => { + it('should build the query form with the current run input value', () => { + const { result } = renderHook(() => useSingleRunFormParams({ + id: 'knowledge-base-1', + payload: createPayload(), + runInputData: { query: 'what is dify' }, + getInputVars: vi.fn(), + setRunInputData: vi.fn(), + toVarInputs: vi.fn(), + })) + + expect(result.current.forms).toHaveLength(1) + expect(result.current.forms[0].inputs).toEqual([{ + label: 'workflow.nodes.common.inputVars', + variable: 'query', + type: InputVarType.paragraph, + required: true, + }]) + expect(result.current.forms[0].values).toEqual({ query: 'what is dify' }) + }) + + it('should update run input data when the query changes', () => { + const setRunInputData = vi.fn() + const { result } = renderHook(() => useSingleRunFormParams({ + id: 'knowledge-base-1', + payload: createPayload(), + runInputData: { query: 'old query' }, + getInputVars: vi.fn(), + setRunInputData, + toVarInputs: vi.fn(), + })) + + act(() => { + result.current.forms[0].onChange({ query: 'new query' }) + }) + + expect(setRunInputData).toHaveBeenCalledWith({ query: 'new query' }) + }) + }) + + describe('Dependencies', () => { + it('should expose the chunk selector as the only dependent variable', () => { + const payload = createPayload({ + index_chunk_variable_selector: ['node-1', 'chunks'], + }) + + const { result } = renderHook(() => useSingleRunFormParams({ + id: 'knowledge-base-1', + payload, + runInputData: {}, + getInputVars: vi.fn(), + setRunInputData: vi.fn(), + toVarInputs: vi.fn(), + })) + + expect(result.current.getDependentVars()).toEqual([['node-1', 'chunks']]) + expect(result.current.getDependentVar('query')).toEqual(['node-1', 'chunks']) + expect(result.current.getDependentVar('other')).toBeUndefined() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/utils.spec.ts b/web/app/components/workflow/nodes/knowledge-base/__tests__/utils.spec.ts similarity index 99% rename from web/app/components/workflow/nodes/knowledge-base/utils.spec.ts rename to web/app/components/workflow/nodes/knowledge-base/__tests__/utils.spec.ts index fc911e0133..394690c963 100644 --- a/web/app/components/workflow/nodes/knowledge-base/utils.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/utils.spec.ts @@ -1,4 +1,4 @@ -import type { KnowledgeBaseNodeType } from './types' +import type { KnowledgeBaseNodeType } from '../types' import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ConfigurationMethodEnum, @@ -9,14 +9,14 @@ import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum, -} from './types' +} from '../types' import { getKnowledgeBaseValidationIssue, getKnowledgeBaseValidationMessage, isHighQualitySearchMethod, isKnowledgeBaseEmbeddingIssue, KnowledgeBaseValidationIssueCode, -} from './utils' +} from '../utils' const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => { return [ diff --git a/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx similarity index 97% rename from web/app/components/workflow/nodes/knowledge-base/components/embedding-model.spec.tsx rename to web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx index fe8cacd76e..db8bdeb0e1 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/embedding-model.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/embedding-model.spec.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { render } from '@testing-library/react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import EmbeddingModel from './embedding-model' +import EmbeddingModel from '../embedding-model' const mockUseModelList = vi.hoisted(() => vi.fn()) const mockModelSelector = vi.hoisted(() => vi.fn(() =>
selector
)) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx new file mode 100644 index 0000000000..a11f93e0b0 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { ChunkStructureEnum, IndexMethodEnum } from '../../types' +import IndexMethod from '../index-method' + +describe('IndexMethod', () => { + it('should render both index method options for general chunks and notify option changes', () => { + const onIndexMethodChange = vi.fn() + + render( + , + ) + + expect(screen.getByText('datasetCreation.stepTwo.qualified')).toBeInTheDocument() + expect(screen.getByText('datasetSettings.form.indexMethodEconomy')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepTwo.recommend')).toBeInTheDocument() + + fireEvent.click(screen.getByText('datasetSettings.form.indexMethodEconomy')) + + expect(onIndexMethodChange).toHaveBeenCalledWith(IndexMethodEnum.ECONOMICAL) + }) + + it('should update the keyword number when the economical option is active', () => { + const onKeywordNumberChange = vi.fn() + const { container } = render( + , + ) + + fireEvent.change(container.querySelector('input') as HTMLInputElement, { target: { value: '7' } }) + + expect(onKeywordNumberChange).toHaveBeenCalledWith(7) + }) + + it('should disable keyword controls when readonly is enabled', () => { + const { container } = render( + , + ) + + expect(container.querySelector('input')).toBeDisabled() + }) + + it('should hide the economical option for non-general chunk structures', () => { + render( + , + ) + + expect(screen.getByText('datasetCreation.stepTwo.qualified')).toBeInTheDocument() + expect(screen.queryByText('datasetSettings.form.indexMethodEconomy')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/option-card.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/option-card.spec.tsx new file mode 100644 index 0000000000..0c4e53b8fd --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/option-card.spec.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import OptionCard from '../option-card' + +describe('OptionCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The card should expose selection, child expansion, and readonly click behavior. + describe('Interaction', () => { + it('should call onClick with the card id and render active children', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + + render( + +
Advanced controls
+
, + ) + + expect(screen.getByText('datasetCreation.stepTwo.recommend')).toBeInTheDocument() + expect(screen.getByText('Advanced controls')).toBeInTheDocument() + + await user.click(screen.getByText('High Quality')) + + expect(onClick).toHaveBeenCalledWith('qualified') + }) + + it('should not trigger selection when the card is readonly', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('Economical')) + + expect(onClick).not.toHaveBeenCalled() + }) + + it('should support function-based wrapper, class, and icon props without enabling selection', () => { + render( + (isActive ? 'wrapper-active' : 'wrapper-inactive')} + className={isActive => (isActive ? 'body-active' : 'body-inactive')} + icon={isActive => {isActive ? 'active' : 'inactive'}} + />, + ) + + expect(screen.getByText('Inactive card').closest('.wrapper-inactive')).toBeInTheDocument() + expect(screen.getByTestId('option-icon')).toHaveTextContent('inactive') + expect(screen.getByText('Inactive card').closest('.body-inactive')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/hooks.spec.tsx new file mode 100644 index 0000000000..a7620d4317 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/hooks.spec.tsx @@ -0,0 +1,47 @@ +import { render, renderHook } from '@testing-library/react' +import { ChunkStructureEnum } from '../../../types' +import { useChunkStructure } from '../hooks' + +const renderIcon = (icon: ReturnType['options'][number]['icon'], isActive: boolean) => { + if (typeof icon !== 'function') + throw new Error('expected icon renderer') + + return icon(isActive) +} + +describe('useChunkStructure', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The hook should expose ordered options and a lookup map for every chunk structure variant. + describe('Options', () => { + it('should return all chunk structure options and map them by id', () => { + const { result } = renderHook(() => useChunkStructure()) + + expect(result.current.options).toHaveLength(3) + expect(result.current.options.map(option => option.id)).toEqual([ + ChunkStructureEnum.general, + ChunkStructureEnum.parent_child, + ChunkStructureEnum.question_answer, + ]) + expect(result.current.optionMap[ChunkStructureEnum.general].title).toBe('datasetCreation.stepTwo.general') + expect(result.current.optionMap[ChunkStructureEnum.parent_child].title).toBe('datasetCreation.stepTwo.parentChild') + expect(result.current.optionMap[ChunkStructureEnum.question_answer].title).toBe('Q&A') + }) + + it('should expose active and inactive icon renderers for every option', () => { + const { result } = renderHook(() => useChunkStructure()) + + const generalInactive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, false)}).container.firstChild as HTMLElement + const generalActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, true)}).container.firstChild as HTMLElement + const parentChildActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.parent_child].icon, true)}).container.firstChild as HTMLElement + const questionAnswerActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.question_answer].icon, true)}).container.firstChild as HTMLElement + + expect(generalInactive).toHaveClass('text-text-tertiary') + expect(generalActive).toHaveClass('text-util-colors-indigo-indigo-600') + expect(parentChildActive).toHaveClass('text-util-colors-blue-light-blue-light-500') + expect(questionAnswerActive).toHaveClass('text-util-colors-teal-teal-600') + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.spec.tsx rename to web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/index.spec.tsx index f93344ca60..454d57e5b5 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/index.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { render, screen } from '@testing-library/react' -import { ChunkStructureEnum } from '../../types' -import ChunkStructure from './index' +import { ChunkStructureEnum } from '../../../types' +import ChunkStructure from '../index' const mockUseChunkStructure = vi.hoisted(() => vi.fn()) @@ -15,15 +15,15 @@ vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({ ), })) -vi.mock('./hooks', () => ({ +vi.mock('../hooks', () => ({ useChunkStructure: mockUseChunkStructure, })) -vi.mock('../option-card', () => ({ +vi.mock('../../option-card', () => ({ default: ({ title }: { title: string }) =>
{title}
, })) -vi.mock('./selector', () => ({ +vi.mock('../selector', () => ({ default: ({ trigger, value }: { trigger?: ReactNode, value?: string }) => (
{value ?? 'no-value'} @@ -32,7 +32,7 @@ vi.mock('./selector', () => ({ ), })) -vi.mock('./instruction', () => ({ +vi.mock('../instruction', () => ({ default: ({ className }: { className?: string }) =>
Instruction
, })) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/selector.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/selector.spec.tsx new file mode 100644 index 0000000000..617944e4ee --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/__tests__/selector.spec.tsx @@ -0,0 +1,58 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { ChunkStructureEnum } from '../../../types' +import Selector from '../selector' + +const options = [ + { + id: ChunkStructureEnum.general, + icon: G, + title: 'General', + description: 'General description', + effectColor: 'blue', + }, + { + id: ChunkStructureEnum.parent_child, + icon: P, + title: 'Parent child', + description: 'Parent child description', + effectColor: 'purple', + }, +] + +describe('ChunkStructureSelector', () => { + it('should open the selector panel and close it after selecting an option', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.change' })) + + expect(screen.getByText('workflow.nodes.knowledgeBase.changeChunkStructure')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Parent child')) + + expect(onChange).toHaveBeenCalledWith(ChunkStructureEnum.parent_child) + expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument() + }) + + it('should not open the selector when readonly is enabled', () => { + render( + custom-trigger} + />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'custom-trigger' })) + + expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/index.spec.tsx new file mode 100644 index 0000000000..20eee01c00 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/index.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import Instruction from '../index' + +const mockUseDocLink = vi.hoisted(() => vi.fn()) + +vi.mock('@/context/i18n', () => ({ + useDocLink: mockUseDocLink, +})) + +describe('ChunkStructureInstruction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseDocLink.mockReturnValue((path: string) => `https://docs.example.com${path}`) + }) + + // The instruction card should render the learning copy and link to the chunking guide. + describe('Rendering', () => { + it('should render the title, message, and learn-more link', () => { + render() + + expect(screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.title')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.message')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'workflow.nodes.knowledgeBase.chunkStructureTip.learnMore' })).toHaveAttribute( + 'href', + 'https://docs.example.com/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text', + ) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/line.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/line.spec.tsx new file mode 100644 index 0000000000..9f6d397e36 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/instruction/__tests__/line.spec.tsx @@ -0,0 +1,27 @@ +import { render } from '@testing-library/react' +import Line from '../line' + +describe('ChunkStructureInstructionLine', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The line should switch between vertical and horizontal SVG assets. + describe('Rendering', () => { + it('should render the vertical line by default', () => { + const { container } = render() + const svg = container.querySelector('svg') + + expect(svg).toHaveAttribute('width', '2') + expect(svg).toHaveAttribute('height', '132') + }) + + it('should render the horizontal line when requested', () => { + const { container } = render() + const svg = container.querySelector('svg') + + expect(svg).toHaveAttribute('width', '240') + expect(svg).toHaveAttribute('height', '2') + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/hooks.spec.tsx new file mode 100644 index 0000000000..ac52e807c9 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/hooks.spec.tsx @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react' +import { + HybridSearchModeEnum, + IndexMethodEnum, + RetrievalSearchMethodEnum, +} from '../../../types' +import { useRetrievalSetting } from '../hooks' + +describe('useRetrievalSetting', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The hook should switch between economical and qualified retrieval option sets. + describe('Options', () => { + it('should return semantic, full-text, and hybrid options for qualified indexing', () => { + const { result } = renderHook(() => useRetrievalSetting(IndexMethodEnum.QUALIFIED)) + + expect(result.current.options.map(option => option.id)).toEqual([ + RetrievalSearchMethodEnum.semantic, + RetrievalSearchMethodEnum.fullText, + RetrievalSearchMethodEnum.hybrid, + ]) + expect(result.current.hybridSearchModeOptions.map(option => option.id)).toEqual([ + HybridSearchModeEnum.WeightedScore, + HybridSearchModeEnum.RerankingModel, + ]) + }) + + it('should return only keyword search for economical indexing', () => { + const { result } = renderHook(() => useRetrievalSetting(IndexMethodEnum.ECONOMICAL)) + + expect(result.current.options.map(option => option.id)).toEqual([ + RetrievalSearchMethodEnum.keywordSearch, + ]) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/index.spec.tsx new file mode 100644 index 0000000000..b07f87ea03 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/index.spec.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react' +import { createDocLinkMock, resolveDocLink } from '@/app/components/workflow/__tests__/i18n' +import { IndexMethodEnum } from '../../../types' +import RetrievalSetting from '../index' + +const mockUseDocLink = createDocLinkMock() + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => mockUseDocLink, +})) + +const baseProps = { + onRetrievalSearchMethodChange: vi.fn(), + onHybridSearchModeChange: vi.fn(), + onWeightedScoreChange: vi.fn(), + onTopKChange: vi.fn(), + onScoreThresholdChange: vi.fn(), + onScoreThresholdEnabledChange: vi.fn(), + onRerankingModelEnabledChange: vi.fn(), + onRerankingModelChange: vi.fn(), + topK: 3, + scoreThreshold: 0.5, + isScoreThresholdEnabled: false, +} + +describe('RetrievalSetting', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the learn-more link and qualified retrieval method options', () => { + render( + , + ) + + expect(screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' })).toHaveAttribute( + 'href', + resolveDocLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods'), + ) + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument() + expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument() + }) + + it('should render only the economical retrieval method for economical indexing', () => { + render( + , + ) + + expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument() + expect(screen.queryByText('dataset.retrieval.hybrid_search.title')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx similarity index 72% rename from web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.spec.tsx rename to web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx index 300de76c2e..7e3f7fdd67 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/reranking-model-selector.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/reranking-model-selector.spec.tsx @@ -1,15 +1,14 @@ import type { DefaultModel, Model, - ModelItem, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { fireEvent, render, screen } from '@testing-library/react' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { - ConfigurationMethodEnum, - ModelStatusEnum, - ModelTypeEnum, -} from '@/app/components/header/account-setting/model-provider-page/declarations' -import RerankingModelSelector from './reranking-model-selector' + createModel, + createModelItem, +} from '@/app/components/workflow/__tests__/model-provider-fixtures' +import RerankingModelSelector from '../reranking-model-selector' type MockModelSelectorProps = { defaultModel?: DefaultModel @@ -37,38 +36,19 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec ), })) -const createModelItem = (overrides: Partial = {}): ModelItem => ({ - model: 'rerank-v3', - label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' }, - model_type: ModelTypeEnum.rerank, - fetch_from: ConfigurationMethodEnum.predefinedModel, - status: ModelStatusEnum.active, - model_properties: {}, - load_balancing_enabled: false, - ...overrides, -}) - -const createModel = (overrides: Partial = {}): Model => ({ - provider: 'cohere', - icon_small: { - en_US: 'https://example.com/cohere.png', - zh_Hans: 'https://example.com/cohere.png', - }, - icon_small_dark: { - en_US: 'https://example.com/cohere-dark.png', - zh_Hans: 'https://example.com/cohere-dark.png', - }, - label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, - models: [createModelItem()], - status: ModelStatusEnum.active, - ...overrides, -}) - describe('RerankingModelSelector', () => { beforeEach(() => { vi.clearAllMocks() mockUseModelListAndDefaultModel.mockReturnValue({ - modelList: [createModel()], + modelList: [createModel({ + provider: 'cohere', + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [createModelItem({ + model: 'rerank-v3', + model_type: ModelTypeEnum.rerank, + label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' }, + })], + })], defaultModel: undefined, }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx new file mode 100644 index 0000000000..62aa379250 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/search-method-option.spec.tsx @@ -0,0 +1,229 @@ +import type { ComponentType, SVGProps } from 'react' +import { + fireEvent, + render, + screen, +} from '@testing-library/react' +import { + HybridSearchModeEnum, + RetrievalSearchMethodEnum, + WeightedScoreEnum, +} from '../../../types' +import SearchMethodOption from '../search-method-option' + +const mockUseModelListAndDefaultModel = vi.hoisted(() => vi.fn()) +const mockUseProviderContext = vi.hoisted(() => vi.fn()) +const mockUseCredentialPanelState = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useModelListAndDefaultModel: (...args: Parameters) => mockUseModelListAndDefaultModel(...args), + } +}) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockUseProviderContext(), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({ + useCredentialPanelState: (...args: unknown[]) => mockUseCredentialPanelState(...args), +})) + +const SearchIcon: ComponentType> = props => ( + +) + +const hybridSearchModeOptions = [ + { + id: HybridSearchModeEnum.WeightedScore, + title: 'Weighted mode', + description: 'Use weighted score', + }, + { + id: HybridSearchModeEnum.RerankingModel, + title: 'Rerank mode', + description: 'Use reranking model', + }, +] + +const weightedScore = { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.8, + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-large', + }, + keyword_setting: { + keyword_weight: 0.2, + }, +} + +const createProps = () => ({ + option: { + id: RetrievalSearchMethodEnum.semantic, + icon: SearchIcon, + title: 'Semantic title', + description: 'Semantic description', + effectColor: 'purple', + }, + hybridSearchModeOptions, + searchMethod: RetrievalSearchMethodEnum.semantic, + onRetrievalSearchMethodChange: vi.fn(), + hybridSearchMode: HybridSearchModeEnum.WeightedScore, + onHybridSearchModeChange: vi.fn(), + weightedScore, + onWeightedScoreChange: vi.fn(), + rerankingModelEnabled: false, + onRerankingModelEnabledChange: vi.fn(), + rerankingModel: { + reranking_provider_name: '', + reranking_model_name: '', + }, + onRerankingModelChange: vi.fn(), + topK: 3, + onTopKChange: vi.fn(), + scoreThreshold: 0.5, + onScoreThresholdChange: vi.fn(), + isScoreThresholdEnabled: true, + onScoreThresholdEnabledChange: vi.fn(), + showMultiModalTip: false, +}) + +describe('SearchMethodOption', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseModelListAndDefaultModel.mockReturnValue({ + modelList: [], + defaultModel: undefined, + }) + mockUseProviderContext.mockReturnValue({ + modelProviders: [], + }) + mockUseCredentialPanelState.mockReturnValue({ + variant: 'api-active', + priority: 'apiKeyOnly', + supportsCredits: false, + showPrioritySwitcher: false, + hasCredentials: true, + isCreditsExhausted: false, + credentialName: undefined, + credits: 0, + }) + }) + + it('should render semantic search controls and notify retrieval and reranking changes', () => { + const props = createProps() + + render() + + expect(screen.getByText('Semantic title')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument() + expect(screen.getAllByRole('switch')).toHaveLength(2) + + fireEvent.click(screen.getByText('Semantic title')) + fireEvent.click(screen.getAllByRole('switch')[0]) + + expect(props.onRetrievalSearchMethodChange).toHaveBeenCalledWith(RetrievalSearchMethodEnum.semantic) + expect(props.onRerankingModelEnabledChange).toHaveBeenCalledWith(true) + }) + + it('should render the reranking switch for full-text search as well', () => { + const props = createProps() + + render( + , + ) + + expect(screen.getByText('Full-text title')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Full-text title')) + + expect(props.onRetrievalSearchMethodChange).toHaveBeenCalledWith(RetrievalSearchMethodEnum.fullText) + }) + + it('should render hybrid weighted-score controls without reranking model selector', () => { + const props = createProps() + + render( + , + ) + + expect(screen.getByText('Weighted mode')).toBeInTheDocument() + expect(screen.getByText('Rerank mode')).toBeInTheDocument() + expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument() + expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument() + expect(screen.queryByText('common.modelProvider.rerankModel.key')).not.toBeInTheDocument() + expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('Rerank mode')) + + expect(props.onHybridSearchModeChange).toHaveBeenCalledWith(HybridSearchModeEnum.RerankingModel) + }) + + it('should render the hybrid reranking selector when reranking mode is selected', () => { + const props = createProps() + + render( + , + ) + + expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument() + expect(screen.queryByText('common.modelProvider.rerankModel.key')).not.toBeInTheDocument() + expect(screen.queryByText('dataset.weightedScore.semantic')).not.toBeInTheDocument() + expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip')).toBeInTheDocument() + }) + + it('should hide the score-threshold control for keyword search', () => { + const props = createProps() + + render( + , + ) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '9' } }) + + expect(screen.getAllByRole('textbox')).toHaveLength(1) + expect(screen.queryAllByRole('switch')).toHaveLength(0) + expect(props.onTopKChange).toHaveBeenCalledWith(9) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx index 762c4c4c05..6de6365c89 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/__tests__/top-k-and-score-threshold.spec.tsx @@ -32,4 +32,38 @@ describe('TopKAndScoreThreshold', () => { expect(defaultProps.onScoreThresholdChange).toHaveBeenCalledWith(0.46) }) + + it('should hide the score-threshold column when requested', () => { + render() + + expect(screen.getAllByRole('textbox')).toHaveLength(1) + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + }) + + it('should fall back to zero when the number fields are cleared', () => { + render( + , + ) + + const [topKInput, scoreThresholdInput] = screen.getAllByRole('textbox') + fireEvent.change(topKInput, { target: { value: '' } }) + + expect(defaultProps.onTopKChange).toHaveBeenCalledWith(0) + expect(scoreThresholdInput).toHaveValue('') + }) + + it('should default the score-threshold switch to off when the flag is missing', () => { + render( + , + ) + + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') + }) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx new file mode 100644 index 0000000000..a5fbe34ec2 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx @@ -0,0 +1,513 @@ +import type { KnowledgeBaseNodeType } from '../../types' +import { act } from '@testing-library/react' +import { + createNode, + createNodeDataFactory, +} from '@/app/components/workflow/__tests__/fixtures' +import { renderWorkflowFlowHook } from '@/app/components/workflow/__tests__/workflow-test-env' +import { RerankingModeEnum } from '@/models/datasets' +import { + ChunkStructureEnum, + HybridSearchModeEnum, + IndexMethodEnum, + RetrievalSearchMethodEnum, + WeightedScoreEnum, +} from '../../types' +import { useConfig } from '../use-config' + +const mockHandleNodeDataUpdateWithSyncDraft = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodeDataUpdate: () => ({ + handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, + }), +})) + +const createNodeData = createNodeDataFactory({ + title: 'Knowledge Base', + desc: '', + type: 'knowledge-base' as KnowledgeBaseNodeType['type'], + index_chunk_variable_selector: ['chunks', 'results'], + chunk_structure: ChunkStructureEnum.general, + indexing_technique: IndexMethodEnum.QUALIFIED, + embedding_model: 'text-embedding-3-large', + embedding_model_provider: 'openai', + keyword_number: 3, + retrieval_model: { + search_method: RetrievalSearchMethodEnum.semantic, + reranking_enable: false, + reranking_mode: RerankingModeEnum.RerankingModel, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + summary_index_setting: { + enable: false, + summary_prompt: 'existing prompt', + }, +}) + +const renderConfigHook = (nodeData: KnowledgeBaseNodeType) => + renderWorkflowFlowHook(() => useConfig('knowledge-base-node'), { + nodes: [ + createNode({ + id: 'knowledge-base-node', + data: nodeData, + }), + ], + edges: [], + }) + +describe('useConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should preserve the current chunk variable selector when the chunk structure does not change', () => { + const { result } = renderConfigHook(createNodeData()) + + act(() => { + result.current.handleChunkStructureChange(ChunkStructureEnum.general) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + chunk_structure: ChunkStructureEnum.general, + index_chunk_variable_selector: ['chunks', 'results'], + }), + }) + }) + + it('should reset chunk variables and keep a high-quality search method when switching chunk structures', () => { + const { result } = renderConfigHook(createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.keywordSearch, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + })) + + act(() => { + result.current.handleChunkStructureChange(ChunkStructureEnum.parent_child) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + chunk_structure: ChunkStructureEnum.parent_child, + indexing_technique: IndexMethodEnum.QUALIFIED, + index_chunk_variable_selector: [], + retrieval_model: expect.objectContaining({ + search_method: RetrievalSearchMethodEnum.keywordSearch, + }), + }), + }) + }) + + it('should preserve semantic search when switching to a structured chunk mode from a high-quality search method', () => { + const { result } = renderConfigHook(createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.semantic, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + })) + + act(() => { + result.current.handleChunkStructureChange(ChunkStructureEnum.question_answer) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + chunk_structure: ChunkStructureEnum.question_answer, + retrieval_model: expect.objectContaining({ + search_method: RetrievalSearchMethodEnum.semantic, + }), + }), + }) + }) + + it('should update the index method and keyword number', () => { + const { result } = renderConfigHook(createNodeData()) + + act(() => { + result.current.handleIndexMethodChange(IndexMethodEnum.ECONOMICAL) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + indexing_technique: IndexMethodEnum.ECONOMICAL, + retrieval_model: expect.objectContaining({ + search_method: RetrievalSearchMethodEnum.keywordSearch, + }), + }), + }) + + act(() => { + result.current.handleIndexMethodChange(IndexMethodEnum.QUALIFIED) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + indexing_technique: IndexMethodEnum.QUALIFIED, + retrieval_model: expect.objectContaining({ + search_method: RetrievalSearchMethodEnum.semantic, + }), + }), + }) + + act(() => { + result.current.handleKeywordNumberChange(9) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: { + keyword_number: 9, + }, + }) + }) + + it('should create default weights when embedding weights are missing and default reranking mode when switching away from hybrid', () => { + const { result } = renderConfigHook(createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.semantic, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + })) + + act(() => { + result.current.handleEmbeddingModelChange({ + embeddingModel: 'text-embedding-3-small', + embeddingModelProvider: 'openai', + }) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + weights: expect.objectContaining({ + vector_setting: expect.objectContaining({ + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-small', + }), + keyword_setting: expect.objectContaining({ + keyword_weight: 0.3, + }), + }), + }), + }), + }) + + act(() => { + result.current.handleRetrievalSearchMethodChange(RetrievalSearchMethodEnum.fullText) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + search_method: RetrievalSearchMethodEnum.fullText, + reranking_mode: RerankingModeEnum.RerankingModel, + }), + }), + }) + }) + + it('should update embedding model weights and retrieval search method defaults', () => { + const { result } = renderConfigHook(createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.semantic, + reranking_enable: false, + reranking_mode: RerankingModeEnum.RerankingModel, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.8, + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-large', + }, + keyword_setting: { + keyword_weight: 0.2, + }, + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + })) + + act(() => { + result.current.handleEmbeddingModelChange({ + embeddingModel: 'text-embedding-3-small', + embeddingModelProvider: 'openai', + }) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + embedding_model: 'text-embedding-3-small', + embedding_model_provider: 'openai', + retrieval_model: expect.objectContaining({ + weights: expect.objectContaining({ + vector_setting: expect.objectContaining({ + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-small', + }), + }), + }), + }), + }) + + act(() => { + result.current.handleRetrievalSearchMethodChange(RetrievalSearchMethodEnum.hybrid) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + search_method: RetrievalSearchMethodEnum.hybrid, + reranking_mode: RerankingModeEnum.RerankingModel, + reranking_enable: true, + }), + }), + }) + }) + + it('should seed hybrid weights and propagate retrieval tuning updates', () => { + const { result } = renderConfigHook(createNodeData({ + retrieval_model: { + search_method: RetrievalSearchMethodEnum.hybrid, + reranking_enable: false, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + })) + + act(() => { + result.current.handleHybridSearchModeChange(HybridSearchModeEnum.WeightedScore) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + reranking_mode: HybridSearchModeEnum.WeightedScore, + reranking_enable: false, + weights: expect.objectContaining({ + vector_setting: expect.objectContaining({ + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-large', + }), + }), + }), + }), + }) + + act(() => { + result.current.handleRerankingModelEnabledChange(true) + result.current.handleWeighedScoreChange({ value: [0.6, 0.4] }) + result.current.handleRerankingModelChange({ + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-v3', + }) + result.current.handleTopKChange(8) + result.current.handleScoreThresholdChange(0.75) + result.current.handleScoreThresholdEnabledChange(true) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(2, { + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + reranking_enable: true, + }), + }), + }) + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(3, { + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + weights: expect.objectContaining({ + weight_type: WeightedScoreEnum.Customized, + vector_setting: expect.objectContaining({ + vector_weight: 0.6, + }), + keyword_setting: expect.objectContaining({ + keyword_weight: 0.4, + }), + }), + }), + }), + }) + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(4, { + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + reranking_model: { + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-v3', + }, + }), + }), + }) + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(5, { + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + top_k: 8, + }), + }), + }) + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(6, { + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + score_threshold: 0.75, + }), + }), + }) + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(7, { + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + score_threshold_enabled: true, + }), + }), + }) + }) + + it('should reuse existing hybrid weights and allow empty embedding defaults', () => { + const { result } = renderConfigHook(createNodeData({ + embedding_model: undefined, + embedding_model_provider: undefined, + retrieval_model: { + search_method: RetrievalSearchMethodEnum.hybrid, + reranking_enable: false, + reranking_mode: RerankingModeEnum.WeightedScore, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.9, + embedding_provider_name: 'existing-provider', + embedding_model_name: 'existing-model', + }, + keyword_setting: { + keyword_weight: 0.1, + }, + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + })) + + act(() => { + result.current.handleHybridSearchModeChange(HybridSearchModeEnum.RerankingModel) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + retrieval_model: expect.objectContaining({ + reranking_mode: HybridSearchModeEnum.RerankingModel, + reranking_enable: true, + weights: expect.objectContaining({ + vector_setting: expect.objectContaining({ + embedding_provider_name: 'existing-provider', + embedding_model_name: 'existing-model', + }), + }), + }), + }), + }) + + act(() => { + result.current.handleEmbeddingModelChange({ + embeddingModel: 'fallback-model', + embeddingModelProvider: '', + }) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: expect.objectContaining({ + embedding_model: 'fallback-model', + embedding_model_provider: '', + retrieval_model: expect.objectContaining({ + weights: expect.objectContaining({ + vector_setting: expect.objectContaining({ + embedding_provider_name: '', + embedding_model_name: 'fallback-model', + }), + }), + }), + }), + }) + }) + + it('should normalize input variables and merge summary index settings', () => { + const { result } = renderConfigHook(createNodeData()) + + act(() => { + result.current.handleInputVariableChange('chunks') + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: { + index_chunk_variable_selector: [], + }, + }) + + act(() => { + result.current.handleInputVariableChange(['payload', 'chunks']) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: { + index_chunk_variable_selector: ['payload', 'chunks'], + }, + }) + + act(() => { + result.current.handleSummaryIndexSettingChange({ + enable: true, + }) + }) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({ + id: 'knowledge-base-node', + data: { + summary_index_setting: { + enable: true, + summary_prompt: 'existing prompt', + }, + }, + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts new file mode 100644 index 0000000000..de44cfa112 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-embedding-model-status.spec.ts @@ -0,0 +1,81 @@ +import { renderHook } from '@testing-library/react' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { + createCredentialState, + createModel, + createModelItem, + createProviderMeta, +} from '@/app/components/workflow/__tests__/model-provider-fixtures' +import { useEmbeddingModelStatus } from '../use-embedding-model-status' + +const mockUseCredentialPanelState = vi.hoisted(() => vi.fn()) +const mockUseProviderContext = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({ + useCredentialPanelState: mockUseCredentialPanelState, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: mockUseProviderContext, +})) + +describe('useEmbeddingModelStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProviderContext.mockReturnValue({ + modelProviders: [createProviderMeta({ + supported_model_types: [ModelTypeEnum.textEmbedding], + })], + }) + mockUseCredentialPanelState.mockReturnValue(createCredentialState()) + }) + + // The hook should resolve provider and model metadata before deriving the final status. + describe('Resolution', () => { + it('should return the matched provider, current model, and active status', () => { + const embeddingModelList = [createModel()] + + const { result } = renderHook(() => useEmbeddingModelStatus({ + embeddingModel: 'text-embedding-3-large', + embeddingModelProvider: 'openai', + embeddingModelList, + })) + + expect(result.current.providerMeta?.provider).toBe('openai') + expect(result.current.modelProvider?.provider).toBe('openai') + expect(result.current.currentModel?.model).toBe('text-embedding-3-large') + expect(result.current.status).toBe('active') + }) + + it('should return incompatible when the provider exists but the selected model is missing', () => { + const embeddingModelList = [ + createModel({ + models: [createModelItem({ model: 'another-model' })], + }), + ] + + const { result } = renderHook(() => useEmbeddingModelStatus({ + embeddingModel: 'text-embedding-3-large', + embeddingModelProvider: 'openai', + embeddingModelList, + })) + + expect(result.current.providerMeta?.provider).toBe('openai') + expect(result.current.currentModel).toBeUndefined() + expect(result.current.status).toBe('incompatible') + }) + + it('should return empty when no embedding model is configured', () => { + const { result } = renderHook(() => useEmbeddingModelStatus({ + embeddingModel: undefined, + embeddingModelProvider: undefined, + embeddingModelList: [], + })) + + expect(result.current.providerMeta).toBeUndefined() + expect(result.current.modelProvider).toBeUndefined() + expect(result.current.currentModel).toBeUndefined() + expect(result.current.status).toBe('empty') + }) + }) +}) diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-settings-display.spec.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-settings-display.spec.ts new file mode 100644 index 0000000000..e0a1791768 --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-settings-display.spec.ts @@ -0,0 +1,26 @@ +import { renderHook } from '@testing-library/react' +import { + IndexMethodEnum, + RetrievalSearchMethodEnum, +} from '../../types' +import { useSettingsDisplay } from '../use-settings-display' + +describe('useSettingsDisplay', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The display map should expose translated labels for all index and retrieval settings. + describe('Translations', () => { + it('should return translated labels for each supported setting key', () => { + const { result } = renderHook(() => useSettingsDisplay()) + + expect(result.current[IndexMethodEnum.QUALIFIED]).toBe('datasetCreation.stepTwo.qualified') + expect(result.current[IndexMethodEnum.ECONOMICAL]).toBe('datasetSettings.form.indexMethodEconomy') + expect(result.current[RetrievalSearchMethodEnum.semantic]).toBe('dataset.retrieval.semantic_search.title') + expect(result.current[RetrievalSearchMethodEnum.fullText]).toBe('dataset.retrieval.full_text_search.title') + expect(result.current[RetrievalSearchMethodEnum.hybrid]).toBe('dataset.retrieval.hybrid_search.title') + expect(result.current[RetrievalSearchMethodEnum.keywordSearch]).toBe('dataset.retrieval.keyword_search.title') + }) + }) +}) diff --git a/web/app/components/workflow/nodes/llm/default.spec.ts b/web/app/components/workflow/nodes/llm/__tests__/default.spec.ts similarity index 89% rename from web/app/components/workflow/nodes/llm/default.spec.ts rename to web/app/components/workflow/nodes/llm/__tests__/default.spec.ts index 938b20be10..7dd221f46c 100644 --- a/web/app/components/workflow/nodes/llm/default.spec.ts +++ b/web/app/components/workflow/nodes/llm/__tests__/default.spec.ts @@ -1,7 +1,7 @@ -import type { LLMNodeType } from './types' +import type { LLMNodeType } from '../types' import { AppModeEnum } from '@/types/app' -import { EditionType, PromptRole } from '../../types' -import nodeDefault from './default' +import { EditionType, PromptRole } from '../../../types' +import nodeDefault from '../default' const t = (key: string) => key diff --git a/web/app/components/workflow/nodes/llm/panel.spec.tsx b/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx similarity index 93% rename from web/app/components/workflow/nodes/llm/panel.spec.tsx rename to web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx index 109174e7d2..ee4891cfa3 100644 --- a/web/app/components/workflow/nodes/llm/panel.spec.tsx +++ b/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx @@ -1,4 +1,4 @@ -import type { LLMNodeType } from './types' +import type { LLMNodeType } from '../types' import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { ProviderContextState } from '@/context/provider-context' import type { PanelProps } from '@/types/workflow' @@ -14,8 +14,8 @@ import { } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useProviderContextSelector } from '@/context/provider-context' import { AppModeEnum } from '@/types/app' -import { BlockEnum } from '../../types' -import Panel from './panel' +import { BlockEnum } from '../../../types' +import Panel from '../panel' const mockUseConfig = vi.fn() @@ -23,7 +23,7 @@ vi.mock('@/context/provider-context', () => ({ useProviderContextSelector: vi.fn(), })) -vi.mock('./use-config', () => ({ +vi.mock('../use-config', () => ({ default: (...args: unknown[]) => mockUseConfig(...args), })) @@ -31,19 +31,19 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-param default: () =>
, })) -vi.mock('./components/config-prompt', () => ({ +vi.mock('../components/config-prompt', () => ({ default: () =>
, })) -vi.mock('../_base/components/config-vision', () => ({ +vi.mock('../../_base/components/config-vision', () => ({ default: () => null, })) -vi.mock('../_base/components/memory-config', () => ({ +vi.mock('../../_base/components/memory-config', () => ({ default: () => null, })) -vi.mock('../_base/components/variable/var-reference-picker', () => ({ +vi.mock('../../_base/components/variable/var-reference-picker', () => ({ default: () => null, })) @@ -55,11 +55,11 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () default: () => null, })) -vi.mock('./components/reasoning-format-config', () => ({ +vi.mock('../components/reasoning-format-config', () => ({ default: () => null, })) -vi.mock('./components/structure-output', () => ({ +vi.mock('../components/structure-output', () => ({ default: () => null, })) diff --git a/web/app/components/workflow/nodes/llm/utils.spec.ts b/web/app/components/workflow/nodes/llm/__tests__/utils.spec.ts similarity index 98% rename from web/app/components/workflow/nodes/llm/utils.spec.ts rename to web/app/components/workflow/nodes/llm/__tests__/utils.spec.ts index 4c916651f6..bc4ca0a2a4 100644 --- a/web/app/components/workflow/nodes/llm/utils.spec.ts +++ b/web/app/components/workflow/nodes/llm/__tests__/utils.spec.ts @@ -1,4 +1,4 @@ -import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from './utils' +import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from '../utils' describe('llm utils', () => { describe('getLLMModelIssue', () => { diff --git a/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx new file mode 100644 index 0000000000..443d34e8d5 --- /dev/null +++ b/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx @@ -0,0 +1,94 @@ +import type { NodeProps } from 'reactflow' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { render, waitFor } from '@testing-library/react' +import { createNode } from '@/app/components/workflow/__tests__/fixtures' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { + useAvailableBlocks, + useIsChatMode, + useNodesInteractions, + useNodesReadOnly, +} from '@/app/components/workflow/hooks' +import { BlockEnum } from '@/app/components/workflow/types' +import LoopStartNode, { LoopStartNodeDumb } from '../index' + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAvailableBlocks: vi.fn(), + useNodesInteractions: vi.fn(), + useNodesReadOnly: vi.fn(), + useIsChatMode: vi.fn(), + } +}) + +const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks) +const mockUseNodesInteractions = vi.mocked(useNodesInteractions) +const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) +const mockUseIsChatMode = vi.mocked(useIsChatMode) + +const createAvailableBlocksResult = (): ReturnType => ({ + getAvailableBlocks: vi.fn(() => ({ + availablePrevBlocks: [], + availableNextBlocks: [], + })), + availablePrevBlocks: [], + availableNextBlocks: [], +}) + +const FlowNode = (props: NodeProps) => ( + +) + +const renderFlowNode = () => + renderWorkflowFlowComponent(
, { + nodes: [createNode({ + id: 'loop-start-node', + type: 'loopStartNode', + data: { + title: 'Loop Start', + desc: '', + type: BlockEnum.LoopStart, + }, + })], + edges: [], + reactFlowProps: { + nodeTypes: { loopStartNode: FlowNode }, + }, + canvasStyle: { + width: 400, + height: 300, + }, + }) + +describe('LoopStartNode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult()) + mockUseNodesInteractions.mockReturnValue({ + handleNodeAdd: vi.fn(), + } as unknown as ReturnType) + mockUseNodesReadOnly.mockReturnValue({ + getNodesReadOnly: () => false, + } as unknown as ReturnType) + mockUseIsChatMode.mockReturnValue(false) + }) + + // The loop start marker should match iteration start behavior in both real and dumb render paths. + describe('Rendering', () => { + it('should render the source handle in the ReactFlow context', async () => { + const { container } = renderFlowNode() + + await waitFor(() => { + expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument() + }) + }) + + it('should render the dumb variant without any source handle', () => { + const { container } = render() + + expect(container.querySelector('[data-handleid="source"]')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/start/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/start/__tests__/node.spec.tsx new file mode 100644 index 0000000000..a6c74eb3f7 --- /dev/null +++ b/web/app/components/workflow/nodes/start/__tests__/node.spec.tsx @@ -0,0 +1,58 @@ +import type { StartNodeType } from '../types' +import { screen } from '@testing-library/react' +import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' +import Node from '../node' + +const createNodeData = (overrides: Partial = {}): StartNodeType => ({ + title: 'Start', + desc: '', + type: BlockEnum.Start, + variables: [{ + label: 'Question', + variable: 'query', + type: InputVarType.textInput, + required: true, + }], + ...overrides, +}) + +describe('StartNode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Start variables should render required metadata and gracefully disappear when empty. + describe('Rendering', () => { + it('should render configured input variables and required markers', () => { + renderNodeComponent(Node, createNodeData({ + variables: [ + { + label: 'Question', + variable: 'query', + type: InputVarType.textInput, + required: true, + }, + { + label: 'Count', + variable: 'count', + type: InputVarType.number, + required: false, + }, + ], + })) + + expect(screen.getByText('query')).toBeInTheDocument() + expect(screen.getByText('count')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.start.required')).toBeInTheDocument() + }) + + it('should render nothing when there are no start variables', () => { + const { container } = renderNodeComponent(Node, createNodeData({ + variables: [], + })) + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/__tests__/node.spec.tsx new file mode 100644 index 0000000000..111f543707 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/node.spec.tsx @@ -0,0 +1,46 @@ +import type { ScheduleTriggerNodeType } from '../types' +import { screen } from '@testing-library/react' +import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { BlockEnum } from '@/app/components/workflow/types' +import Node from '../node' +import { getNextExecutionTime } from '../utils/execution-time-calculator' + +const createNodeData = (overrides: Partial = {}): ScheduleTriggerNodeType => ({ + title: 'Schedule Trigger', + desc: '', + type: BlockEnum.TriggerSchedule, + mode: 'visual', + frequency: 'daily', + timezone: 'UTC', + visual_config: { + time: '11:30 AM', + }, + ...overrides, +}) + +describe('TriggerScheduleNode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The node should surface the computed next execution time for both valid and invalid schedules. + describe('Rendering', () => { + it('should render the next execution label and computed execution time', () => { + const data = createNodeData() + + renderNodeComponent(Node, data) + + expect(screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTime')).toBeInTheDocument() + expect(screen.getByText(getNextExecutionTime(data))).toBeInTheDocument() + }) + + it('should render the placeholder when cron mode has an invalid expression', () => { + renderNodeComponent(Node, createNodeData({ + mode: 'cron', + cron_expression: 'invalid cron', + })) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/__tests__/integration.spec.ts similarity index 97% rename from web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts rename to web/app/components/workflow/nodes/trigger-schedule/utils/__tests__/integration.spec.ts index cfc502d141..9eacc9128d 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/__tests__/integration.spec.ts @@ -1,7 +1,7 @@ -import type { ScheduleTriggerNodeType } from '../types' -import { BlockEnum } from '../../../types' -import { isValidCronExpression, parseCronExpression } from './cron-parser' -import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator' +import type { ScheduleTriggerNodeType } from '../../types' +import { BlockEnum } from '../../../../types' +import { isValidCronExpression, parseCronExpression } from '../cron-parser' +import { getNextExecutionTime, getNextExecutionTimes } from '../execution-time-calculator' // Comprehensive integration tests for cron-parser and execution-time-calculator compatibility describe('cron-parser + execution-time-calculator integration', () => { diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/__tests__/node.spec.tsx new file mode 100644 index 0000000000..1585528ff0 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/node.spec.tsx @@ -0,0 +1,47 @@ +import type { WebhookTriggerNodeType } from '../types' +import { screen } from '@testing-library/react' +import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { BlockEnum } from '@/app/components/workflow/types' +import Node from '../node' + +const createNodeData = (overrides: Partial = {}): WebhookTriggerNodeType => ({ + title: 'Webhook Trigger', + desc: '', + type: BlockEnum.TriggerWebhook, + method: 'POST', + content_type: 'application/json', + headers: [], + params: [], + body: [], + async_mode: false, + status_code: 200, + response_body: '', + variables: [], + ...overrides, +}) + +describe('TriggerWebhookNode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The node should expose the webhook URL and keep a clear fallback for empty data. + describe('Rendering', () => { + it('should render the webhook url when it exists', () => { + renderNodeComponent(Node, createNodeData({ + webhook_url: 'https://example.com/webhook', + })) + + expect(screen.getByText('URL')).toBeInTheDocument() + expect(screen.getByText('https://example.com/webhook')).toBeInTheDocument() + }) + + it('should render the placeholder when the webhook url is empty', () => { + renderNodeComponent(Node, createNodeData({ + webhook_url: '', + })) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/note-node/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/__tests__/index.spec.tsx new file mode 100644 index 0000000000..9814bb63f4 --- /dev/null +++ b/web/app/components/workflow/note-node/__tests__/index.spec.tsx @@ -0,0 +1,138 @@ +import type { NoteNodeType } from '../types' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { createNode } from '../../__tests__/fixtures' +import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env' +import { CUSTOM_NOTE_NODE } from '../constants' +import NoteNode from '../index' +import { NoteTheme } from '../types' + +const { + mockHandleEditorChange, + mockHandleNodeDataUpdateWithSyncDraft, + mockHandleNodeDelete, + mockHandleNodesCopy, + mockHandleNodesDuplicate, + mockHandleShowAuthorChange, + mockHandleThemeChange, + mockSetShortcutsEnabled, +} = vi.hoisted(() => ({ + mockHandleEditorChange: vi.fn(), + mockHandleNodeDataUpdateWithSyncDraft: vi.fn(), + mockHandleNodeDelete: vi.fn(), + mockHandleNodesCopy: vi.fn(), + mockHandleNodesDuplicate: vi.fn(), + mockHandleShowAuthorChange: vi.fn(), + mockHandleThemeChange: vi.fn(), + mockSetShortcutsEnabled: vi.fn(), +})) + +vi.mock('../../hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNodeDataUpdate: () => ({ + handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, + }), + useNodesInteractions: () => ({ + handleNodesCopy: mockHandleNodesCopy, + handleNodesDuplicate: mockHandleNodesDuplicate, + handleNodeDelete: mockHandleNodeDelete, + }), + } +}) + +vi.mock('../hooks', () => ({ + useNote: () => ({ + handleThemeChange: mockHandleThemeChange, + handleEditorChange: mockHandleEditorChange, + handleShowAuthorChange: mockHandleShowAuthorChange, + }), +})) + +vi.mock('../../workflow-history-store', () => ({ + useWorkflowHistoryStore: () => ({ + setShortcutsEnabled: mockSetShortcutsEnabled, + }), +})) + +const createNoteData = (overrides: Partial = {}): NoteNodeType => ({ + title: '', + desc: '', + type: '' as unknown as NoteNodeType['type'], + text: '', + theme: NoteTheme.blue, + author: 'Alice', + showAuthor: true, + width: 240, + height: 88, + selected: true, + ...overrides, +}) + +const renderNoteNode = (dataOverrides: Partial = {}) => { + const nodeData = createNoteData(dataOverrides) + const nodes = [ + createNode({ + id: 'note-1', + type: CUSTOM_NOTE_NODE, + data: nodeData, + selected: !!nodeData.selected, + }), + ] + + return renderWorkflowFlowComponent( +
, + { + nodes, + edges: [], + reactFlowProps: { + nodeTypes: { + [CUSTOM_NOTE_NODE]: NoteNode, + }, + }, + initialStoreState: { + controlPromptEditorRerenderKey: 0, + }, + }, + ) +} + +describe('NoteNode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the toolbar and author for a selected persistent note', async () => { + renderNoteNode() + + expect(screen.getByText('Alice')).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument() + }) + }) + + it('should hide the toolbar for temporary notes', () => { + renderNoteNode({ + _isTempNode: true, + showAuthor: false, + }) + + expect(screen.queryByText('workflow.nodes.note.editor.small')).not.toBeInTheDocument() + }) + + it('should clear the selected state when clicking outside the note', async () => { + renderNoteNode() + + fireEvent.click(document.body) + + await waitFor(() => { + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({ + id: 'note-1', + data: { + selected: false, + }, + }) + }) + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/__tests__/context.spec.tsx b/web/app/components/workflow/note-node/note-editor/__tests__/context.spec.tsx new file mode 100644 index 0000000000..e816a331de --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/__tests__/context.spec.tsx @@ -0,0 +1,138 @@ +import type { LexicalEditor } from 'lexical' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { render, screen, waitFor } from '@testing-library/react' +import { $getRoot } from 'lexical' +import { useEffect } from 'react' +import { NoteEditorContextProvider } from '../context' +import { useStore } from '../store' + +const emptyValue = JSON.stringify({ root: { children: [] } }) +const populatedValue = JSON.stringify({ + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'hello', + type: 'text', + version: 1, + }, + ], + direction: null, + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }, + ], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, +}) + +const readEditorText = (editor: LexicalEditor) => { + let text = '' + + editor.getEditorState().read(() => { + text = $getRoot().getTextContent() + }) + + return text +} + +const ContextProbe = ({ + onReady, +}: { + onReady?: (editor: LexicalEditor) => void +}) => { + const [editor] = useLexicalComposerContext() + const selectedIsBold = useStore(state => state.selectedIsBold) + + useEffect(() => { + onReady?.(editor) + }, [editor, onReady]) + + return
{selectedIsBold ? 'bold' : 'not-bold'}
+} + +describe('NoteEditorContextProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Provider should expose the store and render the wrapped editor tree. + describe('Rendering', () => { + it('should render children with the note editor store defaults', async () => { + let editor: LexicalEditor | null = null + + render( + + (editor = instance)} /> + , + ) + + expect(screen.getByText('not-bold')).toBeInTheDocument() + + await waitFor(() => { + expect(editor).not.toBeNull() + }) + + expect(editor!.isEditable()).toBe(true) + expect(readEditorText(editor!)).toBe('') + }) + }) + + // Invalid or empty editor state should fall back to an empty lexical state. + describe('Editor State Initialization', () => { + it.each([ + { + name: 'value is malformed json', + value: '{invalid', + }, + { + name: 'root has no children', + value: emptyValue, + }, + ])('should use an empty editor state when $name', async ({ value }) => { + let editor: LexicalEditor | null = null + + render( + + (editor = instance)} /> + , + ) + + await waitFor(() => { + expect(editor).not.toBeNull() + }) + + expect(readEditorText(editor!)).toBe('') + }) + + it('should restore lexical content and forward editable prop', async () => { + let editor: LexicalEditor | null = null + + render( + + (editor = instance)} /> + , + ) + + await waitFor(() => { + expect(editor).not.toBeNull() + expect(readEditorText(editor!)).toBe('hello') + }) + + expect(editor!.isEditable()).toBe(false) + }) + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx new file mode 100644 index 0000000000..9631d3e817 --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx @@ -0,0 +1,120 @@ +import type { EditorState, LexicalEditor } from 'lexical' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical' +import { useEffect } from 'react' +import { NoteEditorContextProvider } from '../context' +import Editor from '../editor' + +const emptyValue = JSON.stringify({ root: { children: [] } }) + +const EditorProbe = ({ + onReady, +}: { + onReady?: (editor: LexicalEditor) => void +}) => { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + onReady?.(editor) + }, [editor, onReady]) + + return null +} + +const renderEditor = ( + props: Partial> = {}, + onEditorReady?: (editor: LexicalEditor) => void, +) => { + return render( + + <> + + + + , + ) +} + +describe('Editor', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Editor should render the lexical surface with the provided placeholder. + describe('Rendering', () => { + it('should render the placeholder text and content editable surface', () => { + renderEditor({ placeholder: 'Type note' }) + + expect(screen.getByText('Type note')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + // Focus and blur should toggle workflow shortcuts while editing content. + describe('Focus Management', () => { + it('should disable shortcuts on focus and re-enable them on blur', () => { + const setShortcutsEnabled = vi.fn() + + renderEditor({ setShortcutsEnabled }) + + const contentEditable = screen.getByRole('textbox') + + fireEvent.focus(contentEditable) + fireEvent.blur(contentEditable) + + expect(setShortcutsEnabled).toHaveBeenNthCalledWith(1, false) + expect(setShortcutsEnabled).toHaveBeenNthCalledWith(2, true) + }) + }) + + // Lexical change events should be forwarded to the external onChange callback. + describe('Change Handling', () => { + it('should pass editor updates through onChange', async () => { + const changes: string[] = [] + let editor: LexicalEditor | null = null + const handleChange = (editorState: EditorState) => { + editorState.read(() => { + changes.push($getRoot().getTextContent()) + }) + } + + renderEditor({ onChange: handleChange }, instance => (editor = instance)) + + await waitFor(() => { + expect(editor).not.toBeNull() + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + act(() => { + editor!.update(() => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + paragraph.append($createTextNode('hello')) + root.append(paragraph) + }, { discrete: true }) + }) + + act(() => { + editor!.update(() => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + paragraph.append($createTextNode('hello world')) + root.append(paragraph) + }, { discrete: true }) + }) + + await waitFor(() => { + expect(changes).toContain('hello world') + }) + }) + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ef347e01f2 --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/plugins/format-detector-plugin/__tests__/index.spec.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react' +import { NoteEditorContextProvider } from '../../../context' +import FormatDetectorPlugin from '../index' + +const emptyValue = JSON.stringify({ root: { children: [] } }) + +describe('FormatDetectorPlugin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The plugin should register its observers without rendering extra UI. + describe('Rendering', () => { + it('should mount inside the real note editor context without visible output', () => { + const { container } = render( + + + , + ) + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/index.spec.tsx new file mode 100644 index 0000000000..89c554ed4a --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/index.spec.tsx @@ -0,0 +1,71 @@ +import type { createNoteEditorStore } from '../../../store' +import { act, render, screen, waitFor } from '@testing-library/react' +import { useEffect } from 'react' +import { NoteEditorContextProvider } from '../../../context' +import { useNoteEditorStore } from '../../../store' +import LinkEditorPlugin from '../index' + +type NoteEditorStore = ReturnType + +const emptyValue = JSON.stringify({ root: { children: [] } }) + +const StoreProbe = ({ + onReady, +}: { + onReady?: (store: NoteEditorStore) => void +}) => { + const store = useNoteEditorStore() + + useEffect(() => { + onReady?.(store) + }, [onReady, store]) + + return null +} + +describe('LinkEditorPlugin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Without an anchor element the plugin should stay hidden. + describe('Visibility', () => { + it('should render nothing when no link anchor is selected', () => { + const { container } = render( + + + , + ) + + expect(container).toBeEmptyDOMElement() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('should render the link editor when the store has an anchor element', async () => { + let store: NoteEditorStore | null = null + + render( + + (store = instance)} /> + + , + ) + + await waitFor(() => { + expect(store).not.toBeNull() + }) + + act(() => { + store!.setState({ + linkAnchorElement: document.createElement('a'), + linkOperatorShow: false, + selectedLinkUrl: 'https://example.com', + }) + }) + + await waitFor(() => { + expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx new file mode 100644 index 0000000000..9f36b4a7ac --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, waitFor } from '@testing-library/react' +import { NoteTheme } from '../../../types' +import ColorPicker, { COLOR_LIST } from '../color-picker' + +describe('NoteEditor ColorPicker', () => { + it('should open the palette and apply the selected theme', async () => { + const onThemeChange = vi.fn() + const { container } = render( + , + ) + + const trigger = container.querySelector('[data-state="closed"]') as HTMLElement + + fireEvent.click(trigger) + + const popup = document.body.querySelector('[role="tooltip"]') + + expect(popup).toBeInTheDocument() + + const options = popup?.querySelectorAll('.group.relative') + + expect(options).toHaveLength(COLOR_LIST.length) + + fireEvent.click(options?.[COLOR_LIST.length - 1] as Element) + + expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet) + + await waitFor(() => { + expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/command.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/command.spec.tsx new file mode 100644 index 0000000000..289c5fa6e7 --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/command.spec.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render } from '@testing-library/react' +import Command from '../command' + +const { mockHandleCommand } = vi.hoisted(() => ({ + mockHandleCommand: vi.fn(), +})) + +let mockSelectedState = { + selectedIsBold: false, + selectedIsItalic: false, + selectedIsStrikeThrough: false, + selectedIsLink: false, + selectedIsBullet: false, +} + +vi.mock('../../store', () => ({ + useStore: (selector: (state: typeof mockSelectedState) => unknown) => selector(mockSelectedState), +})) + +vi.mock('../hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCommand: () => ({ + handleCommand: mockHandleCommand, + }), + } +}) + +describe('NoteEditor Command', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSelectedState = { + selectedIsBold: false, + selectedIsItalic: false, + selectedIsStrikeThrough: false, + selectedIsLink: false, + selectedIsBullet: false, + } + }) + + it('should highlight the active command and dispatch it on click', () => { + mockSelectedState.selectedIsBold = true + const { container } = render() + + const trigger = container.querySelector('.cursor-pointer') as HTMLElement + + expect(trigger).toHaveClass('bg-state-accent-active') + + fireEvent.click(trigger) + + expect(mockHandleCommand).toHaveBeenCalledWith('bold') + }) + + it('should keep inactive commands unhighlighted', () => { + const { container } = render() + + const trigger = container.querySelector('.cursor-pointer') as HTMLElement + + expect(trigger).not.toHaveClass('bg-state-accent-active') + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx new file mode 100644 index 0000000000..e94b66e695 --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import FontSizeSelector from '../font-size-selector' + +const { + mockHandleFontSize, + mockHandleOpenFontSizeSelector, +} = vi.hoisted(() => ({ + mockHandleFontSize: vi.fn(), + mockHandleOpenFontSizeSelector: vi.fn(), +})) + +let mockFontSizeSelectorShow = false +let mockFontSize = '12px' + +vi.mock('../hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useFontSize: () => ({ + fontSize: mockFontSize, + fontSizeSelectorShow: mockFontSizeSelectorShow, + handleFontSize: mockHandleFontSize, + handleOpenFontSizeSelector: mockHandleOpenFontSizeSelector, + }), + } +}) + +describe('NoteEditor FontSizeSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFontSizeSelectorShow = false + mockFontSize = '12px' + }) + + it('should show the current font size label and request opening when clicked', () => { + render() + + fireEvent.click(screen.getByText('workflow.nodes.note.editor.small')) + + expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(true) + }) + + it('should select a new font size and close the popup', () => { + mockFontSizeSelectorShow = true + mockFontSize = '14px' + + render() + + fireEvent.click(screen.getByText('workflow.nodes.note.editor.large')) + + expect(screen.getAllByText('workflow.nodes.note.editor.medium').length).toBeGreaterThan(0) + expect(mockHandleFontSize).toHaveBeenCalledWith('16px') + expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx new file mode 100644 index 0000000000..7a28295830 --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { NoteTheme } from '../../../types' +import Toolbar from '../index' + +const { + mockHandleCommand, + mockHandleFontSize, + mockHandleOpenFontSizeSelector, +} = vi.hoisted(() => ({ + mockHandleCommand: vi.fn(), + mockHandleFontSize: vi.fn(), + mockHandleOpenFontSizeSelector: vi.fn(), +})) + +let mockFontSizeSelectorShow = false +let mockFontSize = '14px' +let mockSelectedState = { + selectedIsBold: false, + selectedIsItalic: false, + selectedIsStrikeThrough: false, + selectedIsLink: false, + selectedIsBullet: false, +} + +vi.mock('../../store', () => ({ + useStore: (selector: (state: typeof mockSelectedState) => unknown) => selector(mockSelectedState), +})) + +vi.mock('../hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCommand: () => ({ + handleCommand: mockHandleCommand, + }), + useFontSize: () => ({ + fontSize: mockFontSize, + fontSizeSelectorShow: mockFontSizeSelectorShow, + handleFontSize: mockHandleFontSize, + handleOpenFontSizeSelector: mockHandleOpenFontSizeSelector, + }), + } +}) + +describe('NoteEditor Toolbar', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFontSizeSelectorShow = false + mockFontSize = '14px' + mockSelectedState = { + selectedIsBold: false, + selectedIsItalic: false, + selectedIsStrikeThrough: false, + selectedIsLink: false, + selectedIsBullet: false, + } + }) + + it('should compose the toolbar controls and forward callbacks from color and operator actions', async () => { + const onCopy = vi.fn() + const onDelete = vi.fn() + const onDuplicate = vi.fn() + const onShowAuthorChange = vi.fn() + const onThemeChange = vi.fn() + const { container } = render( + , + ) + + expect(screen.getByText('workflow.nodes.note.editor.medium')).toBeInTheDocument() + + const triggers = container.querySelectorAll('[data-state="closed"]') + + fireEvent.click(triggers[0] as HTMLElement) + + const colorOptions = document.body.querySelectorAll('[role="tooltip"] .group.relative') + + fireEvent.click(colorOptions[colorOptions.length - 1] as Element) + + expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet) + + fireEvent.click(container.querySelectorAll('[data-state="closed"]')[container.querySelectorAll('[data-state="closed"]').length - 1] as HTMLElement) + fireEvent.click(screen.getByText('workflow.common.copy')) + + expect(onCopy).toHaveBeenCalledTimes(1) + + await waitFor(() => { + expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument() + }) + expect(onDelete).not.toHaveBeenCalled() + expect(onDuplicate).not.toHaveBeenCalled() + expect(onShowAuthorChange).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx new file mode 100644 index 0000000000..1870bf913a --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Operator from '../operator' + +const renderOperator = (showAuthor = false) => { + const onCopy = vi.fn() + const onDuplicate = vi.fn() + const onDelete = vi.fn() + const onShowAuthorChange = vi.fn() + + const renderResult = render( + , + ) + + return { + ...renderResult, + onCopy, + onDelete, + onDuplicate, + onShowAuthorChange, + } +} + +describe('NoteEditor Toolbar Operator', () => { + it('should trigger copy, duplicate, and delete from the opened menu', () => { + const { + container, + onCopy, + onDelete, + onDuplicate, + } = renderOperator() + + const trigger = container.querySelector('[data-state="closed"]') as HTMLElement + + fireEvent.click(trigger) + fireEvent.click(screen.getByText('workflow.common.copy')) + + expect(onCopy).toHaveBeenCalledTimes(1) + + fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement) + fireEvent.click(screen.getByText('workflow.common.duplicate')) + + expect(onDuplicate).toHaveBeenCalledTimes(1) + + fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement) + fireEvent.click(screen.getByText('common.operation.delete')) + + expect(onDelete).toHaveBeenCalledTimes(1) + }) + + it('should forward the switch state through onShowAuthorChange', () => { + const { + container, + onShowAuthorChange, + } = renderOperator(true) + + fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement) + fireEvent.click(screen.getByRole('switch')) + + expect(onShowAuthorChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx index ab7ec2ef0e..86d4b63763 100644 --- a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from 'react' -import { act, render, screen, waitFor } from '@testing-library/react' -import ReactFlow, { ReactFlowProvider } from 'reactflow' +import { act, screen, waitFor } from '@testing-library/react' import { FlowType } from '@/types/common' +import { createNode } from '../../__tests__/fixtures' +import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env' import { BlockEnum } from '../../types' import AddBlock from '../add-block' @@ -102,16 +103,8 @@ 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( -
- - - - -
, - ) -} +const renderWithReactFlow = (nodes: Array>) => + renderWorkflowFlowComponent(, { nodes, edges: [] }) describe('AddBlock', () => { beforeEach(() => { @@ -145,7 +138,7 @@ describe('AddBlock', () => { it('should hide the start tab for chat mode and rag pipeline flows', async () => { mockIsChatMode = true - const { rerender } = renderWithReactFlow([]) + const { unmount } = renderWithReactFlow([]) await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) @@ -153,14 +146,8 @@ describe('AddBlock', () => { mockIsChatMode = false mockFlowType = FlowType.ragPipeline - rerender( -
- - - - -
, - ) + unmount() + renderWithReactFlow([]) expect(latestBlockSelectorProps?.showStartTab).toBe(false) }) @@ -182,8 +169,8 @@ describe('AddBlock', () => { 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 } }, + createNode({ id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer } }), + createNode({ id: 'node-2', position: { x: 80, y: 0 }, data: { type: BlockEnum.Answer } }), ]) await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) diff --git a/web/app/components/workflow/operator/__tests__/index.spec.tsx b/web/app/components/workflow/operator/__tests__/index.spec.tsx new file mode 100644 index 0000000000..455f3aa0b5 --- /dev/null +++ b/web/app/components/workflow/operator/__tests__/index.spec.tsx @@ -0,0 +1,136 @@ +import { act, screen } from '@testing-library/react' +import { createNode } from '../../__tests__/fixtures' +import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import Operator from '../index' + +const mockEmit = vi.fn() +const mockDeleteAllInspectorVars = vi.fn() + +vi.mock('../../hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: vi.fn(), + }), + useWorkflowReadOnly: () => ({ + workflowReadOnly: false, + getWorkflowReadOnly: () => false, + }), + } +}) + +vi.mock('../../hooks/use-inspect-vars-crud', () => ({ + default: () => ({ + conversationVars: [], + systemVars: [], + nodesWithInspectVars: [], + deleteAllInspectorVars: mockDeleteAllInspectorVars, + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + emit: mockEmit, + }, + }), +})) + +const originalResizeObserver = globalThis.ResizeObserver +let resizeObserverCallback: ResizeObserverCallback | undefined +const observeSpy = vi.fn() +const disconnectSpy = vi.fn() + +class MockResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeObserverCallback = callback + } + + observe(...args: Parameters) { + observeSpy(...args) + } + + unobserve() { + return undefined + } + + disconnect() { + disconnectSpy() + } +} + +const renderOperator = (initialStoreState: Record = {}) => { + return renderWorkflowFlowComponent( + , + { + nodes: [createNode({ + id: 'node-1', + data: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + }, + })], + edges: [], + initialStoreState, + historyStore: { + nodes: [], + edges: [], + }, + }, + ) +} + +describe('Operator', () => { + beforeEach(() => { + vi.clearAllMocks() + resizeObserverCallback = undefined + vi.stubGlobal('ResizeObserver', MockResizeObserver as unknown as typeof ResizeObserver) + }) + + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver + }) + + it('should keep the operator width on the 400px floor when the available width is smaller', () => { + const { container } = renderOperator({ + workflowCanvasWidth: 620, + rightPanelWidth: 350, + }) + + expect(screen.getByText('workflow.debug.variableInspect.trigger.normal')).toBeInTheDocument() + expect(container.querySelector('div[style*="width: 400px"]')).toBeInTheDocument() + }) + + it('should fall back to auto width before layout metrics are ready', () => { + const { container } = renderOperator() + + expect(container.querySelector('div[style*="width: auto"]')).toBeInTheDocument() + }) + + it('should sync the observed panel size back into the workflow store and disconnect on unmount', () => { + const { store, unmount } = renderOperator({ + workflowCanvasWidth: 900, + rightPanelWidth: 260, + }) + + expect(observeSpy).toHaveBeenCalled() + + act(() => { + resizeObserverCallback?.([ + { + borderBoxSize: [{ inlineSize: 512, blockSize: 188 }], + } as unknown as ResizeObserverEntry, + ], {} as ResizeObserver) + }) + + expect(store.getState().bottomPanelWidth).toBe(512) + expect(store.getState().bottomPanelHeight).toBe(188) + + unmount() + + expect(disconnectSpy).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx index ddefe60b7e..8583ef99a7 100644 --- a/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx @@ -3,11 +3,10 @@ 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 { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env' import { InputVarType, WorkflowRunningStatus } from '../../types' import InputsPanel from '../inputs-panel' @@ -64,18 +63,17 @@ const createHooksStoreProps = ( const renderInputsPanel = ( startNode: ReturnType, - options?: Parameters[1], -) => { - return renderWorkflowComponent( -
- - - - -
, - options, + options?: Omit[1], 'nodes' | 'edges'>, + onRun = vi.fn(), +) => + renderWorkflowFlowComponent( + , + { + nodes: [startNode], + edges: [], + ...options, + }, ) -} describe('InputsPanel', () => { beforeEach(() => { @@ -169,34 +167,24 @@ describe('InputsPanel', () => { const onRun = vi.fn() const handleRun = vi.fn() - renderWorkflowComponent( -
- - - - -
, + renderInputsPanel( + createStartNode({ + data: { + variables: [ + { + type: InputVarType.textInput, + variable: 'question', + label: 'Question', + required: true, + default: 'default question', + }, + ], + }, + }), { hooksStoreProps: createHooksStoreProps({ handleRun }), }, + onRun, ) await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })) @@ -217,36 +205,25 @@ describe('InputsPanel', () => { const onRun = vi.fn() const handleRun = vi.fn() - renderWorkflowComponent( -
- - - - -
, + renderInputsPanel( + createStartNode({ + data: { + variables: [ + { + type: InputVarType.textInput, + variable: 'question', + label: 'Question', + required: true, + }, + { + type: InputVarType.checkbox, + variable: 'confirmed', + label: 'Confirmed', + required: false, + }, + ], + }, + }), { initialStoreState: { inputs: { @@ -266,6 +243,7 @@ describe('InputsPanel', () => { }, }), }, + onRun, ) await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })) diff --git a/web/app/components/workflow/panel/debug-and-preview/index.spec.tsx b/web/app/components/workflow/panel/debug-and-preview/__tests__/index.spec.tsx similarity index 100% rename from web/app/components/workflow/panel/debug-and-preview/index.spec.tsx rename to web/app/components/workflow/panel/debug-and-preview/__tests__/index.spec.tsx diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/empty.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/empty.spec.tsx new file mode 100644 index 0000000000..a5044a22cc --- /dev/null +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/empty.spec.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Empty from '../empty' + +describe('VersionHistory Empty', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Empty state should show the reset action and forward user clicks. + describe('User Interactions', () => { + it('should call onResetFilter when the reset button is clicked', async () => { + const user = userEvent.setup() + const onResetFilter = vi.fn() + + render() + + expect(screen.getByText('workflow.versionHistory.filter.empty')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'workflow.versionHistory.filter.reset' })) + + expect(onResetFilter).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/workflow/panel/version-history-panel/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/workflow/panel/version-history-panel/index.spec.tsx rename to web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx index 1765459bcb..673c84ee12 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx @@ -1,10 +1,16 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { WorkflowVersion } from '../../types' +import { WorkflowVersion } from '../../../types' const mockHandleRestoreFromPublishedWorkflow = vi.fn() const mockHandleLoadBackupDraft = vi.fn() const mockSetCurrentVersion = vi.fn() +type MockWorkflowStoreState = { + setShowWorkflowVersionHistoryPanel: ReturnType + currentVersion: null + setCurrentVersion: typeof mockSetCurrentVersion +} + vi.mock('@/context/app-context', () => ({ useSelector: () => ({ id: 'test-user-id' }), })) @@ -69,7 +75,7 @@ vi.mock('@/service/use-workflow', () => ({ }), })) -vi.mock('../../hooks', () => ({ +vi.mock('../../../hooks', () => ({ useDSL: () => ({ handleExportDSL: vi.fn() }), useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }), useWorkflowRun: () => ({ @@ -78,16 +84,16 @@ vi.mock('../../hooks', () => ({ }), })) -vi.mock('../../hooks-store', () => ({ +vi.mock('../../../hooks-store', () => ({ useHooksStore: () => ({ flowId: 'test-flow-id', flowType: 'workflow', }), })) -vi.mock('../../store', () => ({ - useStore: (selector: (state: any) => any) => { - const state = { +vi.mock('../../../store', () => ({ + useStore: (selector: (state: MockWorkflowStoreState) => T) => { + const state: MockWorkflowStoreState = { setShowWorkflowVersionHistoryPanel: vi.fn(), currentVersion: null, setCurrentVersion: mockSetCurrentVersion, @@ -104,11 +110,11 @@ vi.mock('../../store', () => ({ }), })) -vi.mock('./delete-confirm-modal', () => ({ +vi.mock('../delete-confirm-modal', () => ({ default: () => null, })) -vi.mock('./restore-confirm-modal', () => ({ +vi.mock('../restore-confirm-modal', () => ({ default: () => null, })) @@ -123,7 +129,7 @@ describe('VersionHistoryPanel', () => { describe('Version Click Behavior', () => { it('should call handleLoadBackupDraft when draft version is selected on mount', async () => { - const { VersionHistoryPanel } = await import('./index') + const { VersionHistoryPanel } = await import('../index') render( { }) it('should call handleRestoreFromPublishedWorkflow when clicking published version', async () => { - const { VersionHistoryPanel } = await import('./index') + const { VersionHistoryPanel } = await import('../index') render( ({ + useStore: (selector: (state: { pipelineId?: string }) => unknown) => selector({ pipelineId: undefined }), +})) + +const createVersionHistory = (overrides: Partial = {}): VersionHistory => ({ + id: 'version-1', + graph: { + nodes: [], + edges: [], + viewport: undefined, + }, + features: {}, + created_at: 1710000000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + hash: 'hash-1', + updated_at: 1710000000, + updated_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + tool_published: false, + environment_variables: [], + conversation_variables: [], + rag_pipeline_variables: undefined, + version: '2024-01-01T00:00:00Z', + marked_name: 'Release 1', + marked_comment: 'Initial release', + ...overrides, +}) + +describe('VersionHistoryItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Draft items should auto-select on mount and hide published-only metadata. + describe('Draft Behavior', () => { + it('should auto-select the draft version on mount', async () => { + const onClick = vi.fn() + + render( + , + ) + + expect(screen.getByText('workflow.versionHistory.currentDraft')).toBeInTheDocument() + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith(expect.objectContaining({ + version: WorkflowVersion.Draft, + })) + }) + + expect(screen.queryByText('Initial release')).not.toBeInTheDocument() + }) + }) + + // Published items should expose metadata and the hover context menu. + describe('Published Items', () => { + it('should open the context menu for a latest named version and forward restore', async () => { + const user = userEvent.setup() + const handleClickMenuItem = vi.fn() + const onClick = vi.fn() + + render( + , + ) + + const title = screen.getByText('Release 1') + const itemContainer = title.closest('.group') + if (!itemContainer) + throw new Error('Expected version history item container') + + fireEvent.mouseEnter(itemContainer) + + const triggerButton = await screen.findByRole('button') + await user.click(triggerButton) + + expect(screen.getByText('workflow.versionHistory.latest')).toBeInTheDocument() + expect(screen.getByText('Initial release')).toBeInTheDocument() + expect(screen.getByText(/Alice$/)).toBeInTheDocument() + expect(screen.getByText('workflow.common.restore')).toBeInTheDocument() + expect(screen.getByText('workflow.versionHistory.editVersionInfo')).toBeInTheDocument() + expect(screen.getByText('app.export')).toBeInTheDocument() + expect(screen.getByText('workflow.versionHistory.copyId')).toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + + const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer') + if (!restoreItem) + throw new Error('Expected restore menu item') + + fireEvent.click(restoreItem) + + expect(handleClickMenuItem).toHaveBeenCalledTimes(1) + expect(handleClickMenuItem).toHaveBeenCalledWith( + VersionHistoryContextMenuOptions.restore, + VersionHistoryContextMenuOptions.restore, + ) + }) + + it('should ignore clicks when the item is already selected', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + const item = createVersionHistory() + + render( + , + ) + + await user.click(screen.getByText('Release 1')) + + expect(onClick).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/panel/version-history-panel/filter/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/filter/__tests__/index.spec.tsx new file mode 100644 index 0000000000..a35aeb163c --- /dev/null +++ b/web/app/components/workflow/panel/version-history-panel/filter/__tests__/index.spec.tsx @@ -0,0 +1,102 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { WorkflowVersionFilterOptions } from '../../../../types' +import FilterItem from '../filter-item' +import FilterSwitch from '../filter-switch' +import Filter from '../index' + +describe('VersionHistory Filter Components', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The standalone switch should reflect state and emit checked changes. + describe('FilterSwitch', () => { + it('should render the switch label and emit toggled value', async () => { + const user = userEvent.setup() + const handleSwitch = vi.fn() + + render() + + expect(screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions')).toBeInTheDocument() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') + + await user.click(screen.getByRole('switch')) + + expect(handleSwitch).toHaveBeenCalledWith(true) + }) + }) + + // Filter items should show the current selection and forward the option key. + describe('FilterItem', () => { + it('should call onClick with the selected filter key', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + + const { container } = render( + , + ) + + expect(screen.getByText('Only Yours')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + + await user.click(screen.getByText('Only Yours')) + + expect(onClick).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours) + }) + }) + + // The composed filter popover should open, list options, and delegate actions. + describe('Filter', () => { + it('should open the menu and forward option and switch actions', async () => { + const user = userEvent.setup() + const onClickFilterItem = vi.fn() + const handleSwitch = vi.fn() + + const { container } = render( + , + ) + + const trigger = container.querySelector('.h-6.w-6') + if (!trigger) + throw new Error('Expected filter trigger to exist') + + await user.click(trigger) + + expect(screen.getByText('workflow.versionHistory.filter.all')).toBeInTheDocument() + expect(screen.getByText('workflow.versionHistory.filter.onlyYours')).toBeInTheDocument() + + await user.click(screen.getByText('workflow.versionHistory.filter.onlyYours')) + expect(onClickFilterItem).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours) + + fireEvent.click(screen.getByRole('switch')) + expect(handleSwitch).toHaveBeenCalledWith(true) + }) + + it('should mark the trigger as active when a filter is applied', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.bg-state-accent-active-alt')).toBeInTheDocument() + expect(container.querySelector('.text-text-accent')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/panel/version-history-panel/loading/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/loading/__tests__/index.spec.tsx new file mode 100644 index 0000000000..68fc544156 --- /dev/null +++ b/web/app/components/workflow/panel/version-history-panel/loading/__tests__/index.spec.tsx @@ -0,0 +1,51 @@ +import { render } from '@testing-library/react' +import Loading from '../index' +import Item from '../item' + +describe('VersionHistory Loading', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Individual skeleton items should hide optional rows based on edge flags. + describe('Item', () => { + it('should hide the release note placeholder for the first row', () => { + const { container } = render( + , + ) + + expect(container.querySelectorAll('.opacity-20')).toHaveLength(1) + expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument() + }) + + it('should hide the timeline connector for the last row', () => { + const { container } = render( + , + ) + + expect(container.querySelectorAll('.opacity-20')).toHaveLength(2) + expect(container.querySelector('.absolute.left-4.top-6')).not.toBeInTheDocument() + }) + }) + + // The loading list should render the configured number of timeline skeleton rows. + describe('Loading List', () => { + it('should render eight loading rows with the overlay mask', () => { + const { container } = render() + + expect(container.querySelector('.bg-dataset-chunk-list-mask-bg')).toBeInTheDocument() + expect(container.querySelectorAll('.relative.flex.gap-x-1.p-2')).toHaveLength(8) + expect(container.querySelectorAll('.opacity-20')).toHaveLength(15) + }) + }) +}) diff --git a/web/app/components/workflow/run/__tests__/special-result-panel.spec.tsx b/web/app/components/workflow/run/__tests__/special-result-panel.spec.tsx new file mode 100644 index 0000000000..8e09cf6741 --- /dev/null +++ b/web/app/components/workflow/run/__tests__/special-result-panel.spec.tsx @@ -0,0 +1,168 @@ +import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow' +import { fireEvent, render, screen } from '@testing-library/react' +import { BlockEnum } from '../../types' +import SpecialResultPanel from '../special-result-panel' + +const mocks = vi.hoisted(() => ({ + retryPanel: vi.fn(), + iterationPanel: vi.fn(), + loopPanel: vi.fn(), + agentPanel: vi.fn(), +})) + +vi.mock('../retry-log', () => ({ + RetryResultPanel: ({ list }: { list: NodeTracing[] }) => { + mocks.retryPanel(list) + return
{list.length}
+ }, +})) + +vi.mock('../iteration-log', () => ({ + IterationResultPanel: ({ list }: { list: NodeTracing[][] }) => { + mocks.iterationPanel(list) + return
{list.length}
+ }, +})) + +vi.mock('../loop-log', () => ({ + LoopResultPanel: ({ list }: { list: NodeTracing[][] }) => { + mocks.loopPanel(list) + return
{list.length}
+ }, +})) + +vi.mock('../agent-log', () => ({ + AgentResultPanel: ({ agentOrToolLogItemStack }: { agentOrToolLogItemStack: AgentLogItemWithChildren[] }) => { + mocks.agentPanel(agentOrToolLogItemStack) + return
{agentOrToolLogItemStack.length}
+ }, +})) + +const createNodeTracing = (overrides: Partial = {}): NodeTracing => ({ + id: 'trace-1', + index: 0, + predecessor_node_id: '', + node_id: 'node-1', + node_type: BlockEnum.Code, + title: 'Code', + inputs: {}, + inputs_truncated: false, + process_data: {}, + process_data_truncated: false, + outputs: {}, + outputs_truncated: false, + status: 'succeeded', + error: '', + elapsed_time: 0.2, + metadata: { + iterator_length: 0, + iterator_index: 0, + loop_length: 0, + loop_index: 0, + }, + created_at: 1710000000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + finished_at: 1710000001, + execution_metadata: undefined, + ...overrides, +}) + +const createAgentLogItem = (overrides: Partial = {}): AgentLogItemWithChildren => ({ + node_execution_id: 'exec-1', + message_id: 'message-1', + node_id: 'node-1', + label: 'Step 1', + data: {}, + status: 'succeeded', + children: [], + ...overrides, +}) + +describe('SpecialResultPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The wrapper should isolate clicks from the parent tracing card. + describe('Event Isolation', () => { + it('should stop click propagation at the wrapper level', () => { + const parentClick = vi.fn() + + const { container } = render( +
+ +
, + ) + + const panelRoot = container.firstElementChild?.firstElementChild + if (!panelRoot) + throw new Error('Expected panel root element') + + fireEvent.click(panelRoot) + + expect(parentClick).not.toHaveBeenCalled() + }) + }) + + // Panel branches should render only when their required props are present. + describe('Conditional Panels', () => { + it('should render retry, iteration, loop, and agent panels when their data is provided', () => { + const retryList = [createNodeTracing()] + const iterationList = [[createNodeTracing({ id: 'iter-1' })]] + const loopList = [[createNodeTracing({ id: 'loop-1' })]] + const agentStack = [createAgentLogItem()] + const agentMap = { + 'message-1': [createAgentLogItem()], + } + + render( + , + ) + + expect(screen.getByTestId('retry-result-panel')).toHaveTextContent('1') + expect(screen.getByTestId('iteration-result-panel')).toHaveTextContent('1') + expect(screen.getByTestId('loop-result-panel')).toHaveTextContent('1') + expect(screen.getByTestId('agent-result-panel')).toHaveTextContent('1') + expect(mocks.retryPanel).toHaveBeenCalledWith(retryList) + expect(mocks.iterationPanel).toHaveBeenCalledWith(iterationList) + expect(mocks.loopPanel).toHaveBeenCalledWith(loopList) + expect(mocks.agentPanel).toHaveBeenCalledWith(agentStack) + }) + + it('should keep panels hidden when required guards are missing', () => { + render( + , + ) + + expect(screen.queryByTestId('retry-result-panel')).not.toBeInTheDocument() + expect(screen.queryByTestId('iteration-result-panel')).not.toBeInTheDocument() + expect(screen.queryByTestId('loop-result-panel')).not.toBeInTheDocument() + expect(screen.queryByTestId('agent-result-panel')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/run/__tests__/status-container.spec.tsx b/web/app/components/workflow/run/__tests__/status-container.spec.tsx new file mode 100644 index 0000000000..210d230b91 --- /dev/null +++ b/web/app/components/workflow/run/__tests__/status-container.spec.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import StatusContainer from '../status-container' + +vi.mock('@/hooks/use-theme', () => ({ + default: vi.fn(), +})) + +const mockUseTheme = vi.mocked(useTheme) + +describe('StatusContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType) + }) + + // Status styling should follow the current theme and runtime status. + describe('Status Variants', () => { + it('should render success styling for the light theme', () => { + const { container } = render( + + Finished + , + ) + + expect(screen.getByText('Finished')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('bg-workflow-display-success-bg') + expect(container.firstElementChild).toHaveClass('text-text-success') + expect(container.querySelector('.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight\\.svg\\)\\]')).toBeInTheDocument() + }) + + it('should render failed styling for the dark theme', () => { + mockUseTheme.mockReturnValue({ theme: Theme.dark } as ReturnType) + + const { container } = render( + + Failed + , + ) + + expect(container.firstElementChild).toHaveClass('bg-workflow-display-error-bg') + expect(container.firstElementChild).toHaveClass('text-text-warning') + expect(container.querySelector('.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight-dark\\.svg\\)\\]')).toBeInTheDocument() + }) + + it('should render warning styling for paused runs', () => { + const { container } = render( + + Paused + , + ) + + expect(container.firstElementChild).toHaveClass('bg-workflow-display-warning-bg') + expect(container.firstElementChild).toHaveClass('text-text-destructive') + }) + }) +}) diff --git a/web/app/components/workflow/run/__tests__/status.spec.tsx b/web/app/components/workflow/run/__tests__/status.spec.tsx index 25d3ceb278..01f32c4c47 100644 --- a/web/app/components/workflow/run/__tests__/status.spec.tsx +++ b/web/app/components/workflow/run/__tests__/status.spec.tsx @@ -1,8 +1,9 @@ import type { WorkflowPausedDetailsResponse } from '@/models/log' import { render, screen } from '@testing-library/react' +import { createDocLinkMock, resolveDocLink } from '../../__tests__/i18n' import Status from '../status' -const mockDocLink = vi.fn((path: string) => `https://docs.example.com${path}`) +const mockDocLink = createDocLinkMock() const mockUseWorkflowPausedDetails = vi.fn() vi.mock('@/context/i18n', () => ({ @@ -79,7 +80,7 @@ describe('Status', () => { 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(learnMoreLink).toHaveAttribute('href', resolveDocLink('/use-dify/debug/error-type')) expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/error-type') }) diff --git a/web/app/components/workflow/run/agent-log/__tests__/agent-log-trigger.spec.tsx b/web/app/components/workflow/run/agent-log/__tests__/agent-log-trigger.spec.tsx new file mode 100644 index 0000000000..29919e4ccf --- /dev/null +++ b/web/app/components/workflow/run/agent-log/__tests__/agent-log-trigger.spec.tsx @@ -0,0 +1,112 @@ +import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum } from '../../../types' +import AgentLogTrigger from '../agent-log-trigger' + +const createAgentLogItem = (overrides: Partial = {}): AgentLogItemWithChildren => ({ + node_execution_id: 'exec-1', + message_id: 'message-1', + node_id: 'node-1', + label: 'Step 1', + data: {}, + status: 'succeeded', + children: [], + ...overrides, +}) + +const createNodeTracing = (overrides: Partial = {}): NodeTracing => ({ + id: 'trace-1', + index: 0, + predecessor_node_id: '', + node_id: 'node-1', + node_type: BlockEnum.Agent, + title: 'Agent', + inputs: {}, + inputs_truncated: false, + process_data: {}, + process_data_truncated: false, + outputs: {}, + outputs_truncated: false, + status: 'succeeded', + error: '', + elapsed_time: 0.2, + execution_metadata: { + total_tokens: 0, + total_price: 0, + currency: 'USD', + tool_info: { + agent_strategy: 'Plan and execute', + }, + }, + metadata: { + iterator_length: 0, + iterator_index: 0, + loop_length: 0, + loop_index: 0, + }, + created_at: 1710000000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + finished_at: 1710000001, + agentLog: [createAgentLogItem()], + ...overrides, +}) + +describe('AgentLogTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Agent triggers should expose strategy text and open the log stack payload. + describe('User Interactions', () => { + it('should show the agent strategy and pass the log payload on click', async () => { + const user = userEvent.setup() + const onShowAgentOrToolLog = vi.fn() + const agentLog = [createAgentLogItem({ message_id: 'message-1' })] + + render( + , + ) + + expect(screen.getByText('workflow.nodes.agent.strategy.label')).toBeInTheDocument() + expect(screen.getByText('Plan and execute')).toBeInTheDocument() + expect(screen.getByText('runLog.detail')).toBeInTheDocument() + + await user.click(screen.getByText('Plan and execute')) + + expect(onShowAgentOrToolLog).toHaveBeenCalledWith({ + message_id: 'trace-1', + children: agentLog, + }) + }) + + it('should still open the detail view when no strategy label is available', async () => { + const user = userEvent.setup() + const onShowAgentOrToolLog = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('runLog.detail')) + + expect(onShowAgentOrToolLog).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/workflow/run/loop-log/__tests__/loop-log-trigger.spec.tsx b/web/app/components/workflow/run/loop-log/__tests__/loop-log-trigger.spec.tsx new file mode 100644 index 0000000000..085e680f91 --- /dev/null +++ b/web/app/components/workflow/run/loop-log/__tests__/loop-log-trigger.spec.tsx @@ -0,0 +1,149 @@ +import type { LoopDurationMap, LoopVariableMap, NodeTracing } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum } from '../../../types' +import LoopLogTrigger from '../loop-log-trigger' + +const createNodeTracing = (overrides: Partial = {}): NodeTracing => ({ + id: 'trace-1', + index: 0, + predecessor_node_id: '', + node_id: 'loop-node', + node_type: BlockEnum.Loop, + title: 'Loop', + inputs: {}, + inputs_truncated: false, + process_data: {}, + process_data_truncated: false, + outputs: {}, + outputs_truncated: false, + status: 'succeeded', + error: '', + elapsed_time: 0.2, + execution_metadata: { + total_tokens: 0, + total_price: 0, + currency: 'USD', + }, + metadata: { + iterator_length: 0, + iterator_index: 0, + loop_length: 0, + loop_index: 0, + }, + created_at: 1710000000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + finished_at: 1710000001, + ...overrides, +}) + +describe('LoopLogTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Loop triggers should summarize count/error status and forward structured details. + describe('Structured Detail Handling', () => { + it('should pass existing loop details, durations, and variables to the callback', async () => { + const user = userEvent.setup() + const onShowLoopResultList = vi.fn() + const detailList = [ + [createNodeTracing({ id: 'loop-1-step-1', status: 'succeeded' })], + [createNodeTracing({ id: 'loop-2-step-1', status: 'failed' })], + ] + const loopDurationMap: LoopDurationMap = { 0: 1.2, 1: 2.5 } + const loopVariableMap: LoopVariableMap = { 1: { item: 'alpha' } } + + render( +
+ +
, + ) + + expect(screen.getByText(/workflow\.nodes\.loop\.loop/)).toBeInTheDocument() + expect(screen.getByText(/workflow\.nodes\.loop\.error/)).toBeInTheDocument() + + await user.click(screen.getByRole('button')) + + expect(onShowLoopResultList).toHaveBeenCalledWith(detailList, loopDurationMap, loopVariableMap) + }) + + it('should reconstruct loop detail groups from execution metadata when details are absent', async () => { + const user = userEvent.setup() + const onShowLoopResultList = vi.fn() + const loopDurationMap: LoopDurationMap = { + 'parallel-1': 1.5, + '2': 2.2, + } + const allExecutions = [ + createNodeTracing({ + id: 'parallel-child', + execution_metadata: { + total_tokens: 0, + total_price: 0, + currency: 'USD', + parallel_mode_run_id: 'parallel-1', + }, + }), + createNodeTracing({ + id: 'serial-child', + execution_metadata: { + total_tokens: 0, + total_price: 0, + currency: 'USD', + loop_id: 'loop-node', + loop_index: 2, + }, + }), + ] + + render( + , + ) + + await user.click(screen.getByRole('button')) + + expect(onShowLoopResultList).toHaveBeenCalledTimes(1) + const [structuredList, durations, variableMap] = onShowLoopResultList.mock.calls[0] + expect(structuredList).toHaveLength(2) + expect(structuredList).toEqual( + expect.arrayContaining([ + [allExecutions[0]], + [allExecutions[1]], + ]), + ) + expect(durations).toEqual(loopDurationMap) + expect(variableMap).toEqual({}) + }) + }) +}) diff --git a/web/app/components/workflow/run/retry-log/__tests__/retry-log-trigger.spec.tsx b/web/app/components/workflow/run/retry-log/__tests__/retry-log-trigger.spec.tsx new file mode 100644 index 0000000000..14cc0e653b --- /dev/null +++ b/web/app/components/workflow/run/retry-log/__tests__/retry-log-trigger.spec.tsx @@ -0,0 +1,90 @@ +import type { NodeTracing } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum } from '../../../types' +import RetryLogTrigger from '../retry-log-trigger' + +const createNodeTracing = (overrides: Partial = {}): NodeTracing => ({ + id: 'trace-1', + index: 0, + predecessor_node_id: '', + node_id: 'node-1', + node_type: BlockEnum.Code, + title: 'Code', + inputs: {}, + inputs_truncated: false, + process_data: {}, + process_data_truncated: false, + outputs: {}, + outputs_truncated: false, + status: 'succeeded', + error: '', + elapsed_time: 0.2, + metadata: { + iterator_length: 0, + iterator_index: 0, + loop_length: 0, + loop_index: 0, + }, + created_at: 1710000000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + finished_at: 1710000001, + outputs_full_content: undefined, + execution_metadata: undefined, + extras: undefined, + retryDetail: [], + ...overrides, +}) + +describe('RetryLogTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Clicking the trigger should stop bubbling and expose the retry detail list. + describe('User Interactions', () => { + it('should forward retry details and stop parent clicks', async () => { + const user = userEvent.setup() + const onShowRetryResultList = vi.fn() + const parentClick = vi.fn() + const retryDetail = [ + createNodeTracing({ id: 'retry-1' }), + createNodeTracing({ id: 'retry-2' }), + ] + + render( +
+ +
, + ) + + await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.retry.retries:{"num":2}' })) + + expect(onShowRetryResultList).toHaveBeenCalledWith(retryDetail) + expect(parentClick).not.toHaveBeenCalled() + }) + + it('should fall back to an empty retry list when details are missing', async () => { + const user = userEvent.setup() + const onShowRetryResultList = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button')) + + expect(onShowRetryResultList).toHaveBeenCalledWith([]) + }) + }) +}) diff --git a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts b/web/app/components/workflow/run/utils/format-log/__tests__/graph-to-log-struct.spec.ts similarity index 99% rename from web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts rename to web/app/components/workflow/run/utils/format-log/__tests__/graph-to-log-struct.spec.ts index 10a139ee39..46c1cdb76f 100644 --- a/web/app/components/workflow/run/utils/format-log/graph-to-log-struct.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/__tests__/graph-to-log-struct.spec.ts @@ -1,4 +1,4 @@ -import parseDSL from './graph-to-log-struct' +import parseDSL from '../graph-to-log-struct' describe('parseDSL', () => { it('should parse plain nodes correctly', () => { diff --git a/web/app/components/workflow/run/utils/format-log/agent/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/agent/__tests__/index.spec.ts new file mode 100644 index 0000000000..b147ac8d06 --- /dev/null +++ b/web/app/components/workflow/run/utils/format-log/agent/__tests__/index.spec.ts @@ -0,0 +1,13 @@ +import format from '..' +import { agentNodeData, multiStepsCircle, oneStepCircle } from '../data' + +describe('agent', () => { + it('list should transform to tree', () => { + expect(format(agentNodeData.in as unknown as Parameters[0])).toEqual(agentNodeData.expect) + }) + + it('list should remove circle log item', () => { + expect(format(oneStepCircle.in as unknown as Parameters[0])).toEqual(oneStepCircle.expect) + expect(format(multiStepsCircle.in as unknown as Parameters[0])).toEqual(multiStepsCircle.expect) + }) +}) diff --git a/web/app/components/workflow/run/utils/format-log/agent/index.spec.ts b/web/app/components/workflow/run/utils/format-log/agent/index.spec.ts deleted file mode 100644 index 9359e227be..0000000000 --- a/web/app/components/workflow/run/utils/format-log/agent/index.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import format from '.' -import { agentNodeData, multiStepsCircle, oneStepCircle } from './data' - -describe('agent', () => { - it('list should transform to tree', () => { - // console.log(format(agentNodeData.in as any)) - expect(format(agentNodeData.in as any)).toEqual(agentNodeData.expect) - }) - - it('list should remove circle log item', () => { - // format(oneStepCircle.in as any) - expect(format(oneStepCircle.in as any)).toEqual(oneStepCircle.expect) - expect(format(multiStepsCircle.in as any)).toEqual(multiStepsCircle.expect) - }) -}) diff --git a/web/app/components/workflow/run/utils/format-log/iteration/index.spec.ts b/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts similarity index 59% rename from web/app/components/workflow/run/utils/format-log/iteration/index.spec.ts rename to web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts index f984dbea76..5b427bd9cf 100644 --- a/web/app/components/workflow/run/utils/format-log/iteration/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts @@ -1,16 +1,16 @@ +import type { NodeTracing } from '@/types/workflow' import { noop } from 'es-toolkit/function' -import format from '.' -import graphToLogStruct from '../graph-to-log-struct' +import format from '..' +import graphToLogStruct from '../../graph-to-log-struct' describe('iteration', () => { const list = graphToLogStruct('start -> (iteration, iterationNode, plainNode1 -> plainNode2)') - // const [startNode, iterationNode, ...iterations] = list - const result = format(list as any, noop) + const result = format(list as NodeTracing[], noop) it('result should have no nodes in iteration node', () => { - expect((result as any).find((item: any) => !!item.execution_metadata?.iteration_id)).toBeUndefined() + expect(result.find(item => !!item.execution_metadata?.iteration_id)).toBeUndefined() }) // test('iteration should put nodes in details', () => { - // expect(result as any).toEqual([ + // expect(result).toEqual([ // startNode, // { // ...iterationNode, diff --git a/web/app/components/workflow/run/utils/format-log/loop/index.spec.ts b/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts similarity index 75% rename from web/app/components/workflow/run/utils/format-log/loop/index.spec.ts rename to web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts index d2a2fd24bb..f352598943 100644 --- a/web/app/components/workflow/run/utils/format-log/loop/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts @@ -1,11 +1,12 @@ +import type { NodeTracing } from '@/types/workflow' import { noop } from 'es-toolkit/function' -import format from '.' -import graphToLogStruct from '../graph-to-log-struct' +import format from '..' +import graphToLogStruct from '../../graph-to-log-struct' describe('loop', () => { const list = graphToLogStruct('start -> (loop, loopNode, plainNode1 -> plainNode2)') const [startNode, loopNode, ...loops] = list - const result = format(list as any, noop) + const result = format(list as NodeTracing[], noop) it('result should have no nodes in loop node', () => { expect(result.find(item => !!item.execution_metadata?.loop_id)).toBeUndefined() }) diff --git a/web/app/components/workflow/run/utils/format-log/retry/index.spec.ts b/web/app/components/workflow/run/utils/format-log/retry/__tests__/index.spec.ts similarity index 72% rename from web/app/components/workflow/run/utils/format-log/retry/index.spec.ts rename to web/app/components/workflow/run/utils/format-log/retry/__tests__/index.spec.ts index cb823a0e91..7d497061f6 100644 --- a/web/app/components/workflow/run/utils/format-log/retry/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/retry/__tests__/index.spec.ts @@ -1,11 +1,12 @@ -import format from '.' -import graphToLogStruct from '../graph-to-log-struct' +import type { NodeTracing } from '@/types/workflow' +import format from '..' +import graphToLogStruct from '../../graph-to-log-struct' describe('retry', () => { // retry nodeId:1 3 times. const steps = graphToLogStruct('start -> (retry, retryNode, 3)') const [startNode, retryNode, ...retryDetail] = steps - const result = format(steps as any) + const result = format(steps as NodeTracing[]) it('should have no retry status nodes', () => { expect(result.find(item => item.status === 'retry')).toBeUndefined() }) diff --git a/web/app/components/workflow/utils/plugin-install-check.spec.ts b/web/app/components/workflow/utils/__tests__/plugin-install-check.spec.ts similarity index 96% rename from web/app/components/workflow/utils/plugin-install-check.spec.ts rename to web/app/components/workflow/utils/__tests__/plugin-install-check.spec.ts index e37315328e..a2401ea3ac 100644 --- a/web/app/components/workflow/utils/plugin-install-check.spec.ts +++ b/web/app/components/workflow/utils/__tests__/plugin-install-check.spec.ts @@ -1,14 +1,14 @@ -import type { TriggerWithProvider } from '../block-selector/types' -import type { CommonNodeType, ToolWithProvider } from '../types' +import type { TriggerWithProvider } from '../../block-selector/types' +import type { CommonNodeType, ToolWithProvider } from '../../types' import { CollectionType } from '@/app/components/tools/types' -import { BlockEnum } from '../types' +import { BlockEnum } from '../../types' import { isNodePluginMissing, isPluginDependentNode, matchDataSource, matchToolInCollection, matchTriggerProvider, -} from './plugin-install-check' +} from '../plugin-install-check' const createTool = (overrides: Partial = {}): ToolWithProvider => ({ id: 'langgenius/search/search', diff --git a/web/app/components/workflow/variable-inspect/__tests__/empty.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/empty.spec.tsx new file mode 100644 index 0000000000..032bf88708 --- /dev/null +++ b/web/app/components/workflow/variable-inspect/__tests__/empty.spec.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react' +import { createDocLinkMock, resolveDocLink } from '../../__tests__/i18n' +import Empty from '../empty' + +const mockDocLink = createDocLinkMock() + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => mockDocLink, +})) + +describe('VariableInspect Empty', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the empty-state copy and docs link', () => { + render() + + const link = screen.getByRole('link', { name: 'workflow.debug.variableInspect.emptyLink' }) + + expect(screen.getByText('workflow.debug.variableInspect.title')).toBeInTheDocument() + expect(screen.getByText('workflow.debug.variableInspect.emptyTip')).toBeInTheDocument() + expect(link).toHaveAttribute('href', resolveDocLink('/use-dify/debug/variable-inspect')) + expect(link).toHaveAttribute('target', '_blank') + expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/variable-inspect') + }) +}) diff --git a/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx new file mode 100644 index 0000000000..9c64466d56 --- /dev/null +++ b/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx @@ -0,0 +1,131 @@ +import type { NodeWithVar, VarInInspect } from '@/types/workflow' +import { fireEvent, render, screen } from '@testing-library/react' +import { VarInInspectType } from '@/types/workflow' +import { BlockEnum, VarType } from '../../types' +import Group from '../group' + +const mockUseToolIcon = vi.fn(() => '') + +vi.mock('../../hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useToolIcon: () => mockUseToolIcon(), + } +}) + +const createVar = (overrides: Partial = {}): VarInInspect => ({ + id: 'var-1', + type: VarInInspectType.node, + name: 'message', + description: '', + selector: ['node-1', 'message'], + value_type: VarType.string, + value: 'hello', + edited: false, + visible: true, + is_truncated: false, + full_content: { + size_bytes: 0, + download_url: '', + }, + ...overrides, +}) + +const createNodeData = (overrides: Partial = {}): NodeWithVar => ({ + nodeId: 'node-1', + nodePayload: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + }, + nodeType: BlockEnum.Code, + title: 'Code', + vars: [], + ...overrides, +}) + +describe('VariableInspect Group', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should mask secret environment variables before selecting them', () => { + const handleSelect = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText('API_KEY')) + + expect(screen.getByText('workflow.debug.variableInspect.envNode')).toBeInTheDocument() + expect(handleSelect).toHaveBeenCalledWith({ + nodeId: VarInInspectType.environment, + nodeType: VarInInspectType.environment, + title: VarInInspectType.environment, + var: expect.objectContaining({ + id: 'env-secret', + type: VarInInspectType.environment, + value: '******************', + }), + }) + }) + + it('should hide invisible variables and collapse the list when the group header is clicked', () => { + render( + , + ) + + expect(screen.getByText('visible_var')).toBeInTheDocument() + expect(screen.queryByText('hidden_var')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('Code')) + + expect(screen.queryByText('visible_var')).not.toBeInTheDocument() + }) + + it('should expose node view and clear actions for node groups', () => { + const handleView = vi.fn() + const handleClear = vi.fn() + + render( + , + ) + + const actionButtons = screen.getAllByRole('button') + + fireEvent.click(actionButtons[0]) + fireEvent.click(actionButtons[1]) + + expect(handleView).toHaveBeenCalledTimes(1) + expect(handleClear).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx new file mode 100644 index 0000000000..ce180b2531 --- /dev/null +++ b/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import LargeDataAlert from '../large-data-alert' + +describe('LargeDataAlert', () => { + it('should render the default message and export action when a download URL exists', () => { + const { container } = render() + + expect(screen.getByText('workflow.debug.variableInspect.largeData')).toBeInTheDocument() + expect(screen.getByText('workflow.debug.variableInspect.export')).toBeInTheDocument() + expect(container.firstChild).toHaveClass('extra-alert') + }) + + it('should render the no-export message and omit the export action when the URL is missing', () => { + render() + + expect(screen.getByText('workflow.debug.variableInspect.largeDataNoExport')).toBeInTheDocument() + expect(screen.queryByText('workflow.debug.variableInspect.export')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx new file mode 100644 index 0000000000..2bd1fbb00f --- /dev/null +++ b/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx @@ -0,0 +1,173 @@ +import type { EnvironmentVariable } from '../../types' +import type { NodeWithVar, VarInInspect } from '@/types/workflow' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import Panel from '../panel' +import { EVENT_WORKFLOW_STOP } from '../types' + +type InspectVarsState = { + conversationVars: VarInInspect[] + systemVars: VarInInspect[] + nodesWithInspectVars: NodeWithVar[] +} + +const { + mockEditInspectVarValue, + mockEmit, + mockFetchInspectVarValue, + mockHandleNodeSelect, + mockResetConversationVar, + mockResetToLastRunVar, + mockSetInputs, +} = vi.hoisted(() => ({ + mockEditInspectVarValue: vi.fn(), + mockEmit: vi.fn(), + mockFetchInspectVarValue: vi.fn(), + mockHandleNodeSelect: vi.fn(), + mockResetConversationVar: vi.fn(), + mockResetToLastRunVar: vi.fn(), + mockSetInputs: vi.fn(), +})) + +let inspectVarsState: InspectVarsState + +vi.mock('../../hooks/use-inspect-vars-crud', () => ({ + default: () => ({ + ...inspectVarsState, + deleteAllInspectorVars: vi.fn(), + deleteNodeInspectorVars: vi.fn(), + editInspectVarValue: mockEditInspectVarValue, + fetchInspectVarValue: mockFetchInspectVarValue, + resetConversationVar: mockResetConversationVar, + resetToLastRunVar: mockResetToLastRunVar, + }), +})) + +vi.mock('../../nodes/_base/components/variable/use-match-schema-type', () => ({ + default: () => ({ + isLoading: false, + schemaTypeDefinitions: {}, + }), +})) + +vi.mock('../../hooks/use-nodes-interactions', () => ({ + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, + }), +})) + +vi.mock('../../hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, + }), + useToolIcon: () => '', + } +}) + +vi.mock('../../nodes/_base/hooks/use-node-crud', () => ({ + default: () => ({ + setInputs: mockSetInputs, + }), +})) + +vi.mock('../../nodes/_base/hooks/use-node-info', () => ({ + default: () => ({ + node: undefined, + }), +})) + +vi.mock('../../hooks-store', () => ({ + useHooksStore: (selector: (state: { configsMap?: { flowId: string } }) => T) => + selector({ + configsMap: { + flowId: 'flow-1', + }, + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + emit: mockEmit, + }, + }), +})) + +const createEnvironmentVariable = (overrides: Partial = {}): EnvironmentVariable => ({ + id: 'env-1', + name: 'API_KEY', + value: 'env-value', + value_type: 'string', + description: '', + ...overrides, +}) + +const renderPanel = (initialStoreState: Record = {}) => { + return renderWorkflowFlowComponent( + , + { + nodes: [], + edges: [], + initialStoreState, + historyStore: { + nodes: [], + edges: [], + }, + }, + ) +} + +describe('VariableInspect Panel', () => { + beforeEach(() => { + vi.clearAllMocks() + inspectVarsState = { + conversationVars: [], + systemVars: [], + nodesWithInspectVars: [], + } + }) + + it('should render the listening state and stop the workflow on demand', () => { + renderPanel({ + isListening: true, + listeningTriggerType: BlockEnum.TriggerWebhook, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.listening.stopButton' })) + + expect(screen.getByText('workflow.debug.variableInspect.listening.title')).toBeInTheDocument() + expect(mockEmit).toHaveBeenCalledWith({ + type: EVENT_WORKFLOW_STOP, + }) + }) + + it('should render the empty state and close the panel from the header action', () => { + const { store } = renderPanel({ + showVariableInspectPanel: true, + }) + + fireEvent.click(screen.getAllByRole('button')[0]) + + expect(screen.getByText('workflow.debug.variableInspect.emptyTip')).toBeInTheDocument() + expect(store.getState().showVariableInspectPanel).toBe(false) + }) + + it('should select an environment variable and show its details in the right panel', async () => { + renderPanel({ + environmentVariables: [createEnvironmentVariable()], + bottomPanelWidth: 560, + }) + + fireEvent.click(screen.getByText('API_KEY')) + + await waitFor(() => expect(screen.getAllByText('API_KEY').length).toBeGreaterThan(1)) + + expect(screen.getByText('workflow.debug.variableInspect.envNode')).toBeInTheDocument() + expect(screen.getAllByText('string').length).toBeGreaterThan(0) + expect(screen.getByText('env-value')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/variable-inspect/__tests__/trigger.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/trigger.spec.tsx new file mode 100644 index 0000000000..6d2f2ffc02 --- /dev/null +++ b/web/app/components/workflow/variable-inspect/__tests__/trigger.spec.tsx @@ -0,0 +1,153 @@ +import type { NodeWithVar, VarInInspect } from '@/types/workflow' +import { fireEvent, screen } from '@testing-library/react' +import { VarInInspectType } from '@/types/workflow' +import { createNode } from '../../__tests__/fixtures' +import { baseRunningData, renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env' +import { BlockEnum, NodeRunningStatus, VarType, WorkflowRunningStatus } from '../../types' +import VariableInspectTrigger from '../trigger' + +type InspectVarsState = { + conversationVars: VarInInspect[] + systemVars: VarInInspect[] + nodesWithInspectVars: NodeWithVar[] +} + +const { + mockDeleteAllInspectorVars, + mockEmit, +} = vi.hoisted(() => ({ + mockDeleteAllInspectorVars: vi.fn(), + mockEmit: vi.fn(), +})) + +let inspectVarsState: InspectVarsState + +vi.mock('../../hooks/use-inspect-vars-crud', () => ({ + default: () => ({ + ...inspectVarsState, + deleteAllInspectorVars: mockDeleteAllInspectorVars, + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + emit: mockEmit, + }, + }), +})) + +const createVariable = (overrides: Partial = {}): VarInInspect => ({ + id: 'var-1', + type: VarInInspectType.node, + name: 'result', + description: '', + selector: ['node-1', 'result'], + value_type: VarType.string, + value: 'cached', + edited: false, + visible: true, + is_truncated: false, + full_content: { + size_bytes: 0, + download_url: '', + }, + ...overrides, +}) + +const renderTrigger = ({ + nodes = [createNode()], + initialStoreState = {}, +}: { + nodes?: Array> + initialStoreState?: Record +} = {}) => { + return renderWorkflowFlowComponent(, { nodes, edges: [], initialStoreState }) +} + +describe('VariableInspectTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + inspectVarsState = { + conversationVars: [], + systemVars: [], + nodesWithInspectVars: [], + } + }) + + it('should stay hidden when the variable-inspect panel is already open', () => { + renderTrigger({ + initialStoreState: { + showVariableInspectPanel: true, + }, + }) + + expect(screen.queryByText('workflow.debug.variableInspect.trigger.normal')).not.toBeInTheDocument() + }) + + it('should open the panel from the normal trigger state', () => { + const { store } = renderTrigger() + + fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.normal')) + + expect(store.getState().showVariableInspectPanel).toBe(true) + }) + + it('should block opening while the workflow is read only', () => { + const { store } = renderTrigger({ + initialStoreState: { + isRestoring: true, + }, + }) + + fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.normal')) + + expect(store.getState().showVariableInspectPanel).toBe(false) + }) + + it('should clear cached variables and reset the focused node', () => { + inspectVarsState = { + conversationVars: [createVariable({ + id: 'conversation-var', + type: VarInInspectType.conversation, + })], + systemVars: [], + nodesWithInspectVars: [], + } + + const { store } = renderTrigger({ + initialStoreState: { + currentFocusNodeId: 'node-2', + }, + }) + + fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.clear')) + + expect(screen.getByText('workflow.debug.variableInspect.trigger.cached')).toBeInTheDocument() + expect(mockDeleteAllInspectorVars).toHaveBeenCalledTimes(1) + expect(store.getState().currentFocusNodeId).toBe('') + }) + + it('should show the running state and open the panel while running', () => { + const { store } = renderTrigger({ + nodes: [createNode({ + data: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + _singleRunningStatus: NodeRunningStatus.Running, + }, + })], + initialStoreState: { + workflowRunningData: baseRunningData({ + result: { status: WorkflowRunningStatus.Running }, + }), + }, + }) + + fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.running')) + + expect(screen.queryByText('workflow.debug.variableInspect.trigger.clear')).not.toBeInTheDocument() + expect(store.getState().showVariableInspectPanel).toBe(true) + }) +}) diff --git a/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx new file mode 100644 index 0000000000..54a7969049 --- /dev/null +++ b/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx @@ -0,0 +1,47 @@ +import { render, waitFor } from '@testing-library/react' +import WorkflowPreview from '../index' + +const defaultViewport = { + x: 0, + y: 0, + zoom: 1, +} + +describe('WorkflowPreview', () => { + it('should render the preview container with the default left minimap placement', async () => { + const { container } = render( +
+ +
, + ) + + await waitFor(() => expect(container.querySelector('.react-flow__minimap')).toBeInTheDocument()) + + expect(container.querySelector('#workflow-container')).toHaveClass('preview-shell') + expect(container.querySelector('.react-flow__background')).toBeInTheDocument() + expect(container.querySelector('.react-flow__minimap')).toHaveClass('!left-4') + }) + + it('should move the minimap to the right when requested', async () => { + const { container } = render( +
+ +
, + ) + + await waitFor(() => expect(container.querySelector('.react-flow__minimap')).toBeInTheDocument()) + + expect(container.querySelector('.react-flow__minimap')).toHaveClass('!right-4') + expect(container.querySelector('.react-flow__minimap')).not.toHaveClass('!left-4') + }) +}) 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 index b4e06676cd..83e964c864 100644 --- 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 @@ -1,7 +1,8 @@ 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 { screen, waitFor } from '@testing-library/react' +import { createNode } from '@/app/components/workflow/__tests__/fixtures' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' 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' @@ -19,27 +20,18 @@ const ErrorNode = ({ id, data }: NodeProps) => (
) -const renderErrorNode = (data: CommonNodeType) => { - return render( -
- - - -
, - ) -} +const renderErrorNode = (data: CommonNodeType) => + renderWorkflowFlowComponent(
, { + nodes: [createNode({ + id: 'node-1', + type: 'errorNode', + data, + })], + edges: [], + reactFlowProps: { + nodeTypes: { errorNode: ErrorNode }, + }, + }) describe('ErrorHandleOnNode', () => { // Empty and default-value states. 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 index a354ee9afb..a783523929 100644 --- 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 @@ -1,7 +1,8 @@ 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 { waitFor } from '@testing-library/react' +import { createNode } from '@/app/components/workflow/__tests__/fixtures' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { BlockEnum } from '@/app/components/workflow/types' import { NodeSourceHandle, NodeTargetHandle } from '../node-handle' @@ -34,30 +35,21 @@ const SourceHandleNode = ({ id, data }: NodeProps) => (
) -const renderFlowNode = (type: 'targetNode' | 'sourceNode', data: CommonNodeType) => { - return render( -
- - - -
, - ) -} +const renderFlowNode = (type: 'targetNode' | 'sourceNode', data: CommonNodeType) => + renderWorkflowFlowComponent(
, { + nodes: [createNode({ + id: 'node-1', + type, + data, + })], + edges: [], + reactFlowProps: { + nodeTypes: { + targetNode: TargetHandleNode, + sourceNode: SourceHandleNode, + }, + }, + }) describe('node-handle', () => { // Target handle states and visibility rules. @@ -74,36 +66,28 @@ describe('node-handle', () => { }) it('should merge custom classes and hide start-like nodes completely', async () => { - const { container } = render( -
- - ) => ( -
- -
- ), - }} - /> -
-
, - ) + const { container } = renderWorkflowFlowComponent(
, { + nodes: [createNode({ + id: 'node-2', + type: 'targetNode', + data: createNodeData({ type: BlockEnum.Start }), + })], + edges: [], + reactFlowProps: { + nodeTypes: { + targetNode: ({ id, data }: NodeProps) => ( +
+ +
+ ), + }, + }, + }) await waitFor(() => expect(container.querySelector('.custom-target')).toBeInTheDocument()) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 218ff71721..681e430f55 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -8752,11 +8752,6 @@ "count": 1 } }, - "app/components/workflow/panel/version-history-panel/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/workflow/panel/version-history-panel/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -8921,11 +8916,6 @@ "count": 2 } }, - "app/components/workflow/run/utils/format-log/agent/index.spec.ts": { - "ts/no-explicit-any": { - "count": 3 - } - }, "app/components/workflow/run/utils/format-log/agent/index.ts": { "ts/no-explicit-any": { "count": 11 @@ -8941,21 +8931,11 @@ "count": 2 } }, - "app/components/workflow/run/utils/format-log/iteration/index.spec.ts": { - "ts/no-explicit-any": { - "count": 3 - } - }, "app/components/workflow/run/utils/format-log/iteration/index.ts": { "ts/no-explicit-any": { "count": 1 } }, - "app/components/workflow/run/utils/format-log/loop/index.spec.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/workflow/run/utils/format-log/loop/index.ts": { "ts/no-explicit-any": { "count": 1 @@ -8969,11 +8949,6 @@ "count": 2 } }, - "app/components/workflow/run/utils/format-log/retry/index.spec.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/workflow/selection-contextmenu.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 diff --git a/web/utils/semver.ts b/web/utils/semver.ts index a22d219947..86ed2b7224 100644 --- a/web/utils/semver.ts +++ b/web/utils/semver.ts @@ -1,19 +1,21 @@ import { compare, greaterOrEqual, lessThan, parse } from 'std-semver' +const parseVersion = (version: string) => parse(version) + export const getLatestVersion = (versionList: string[]) => { return [...versionList].sort((versionA, versionB) => { - return compare(parse(versionB), parse(versionA)) + return compare(parseVersion(versionB), parseVersion(versionA)) })[0] } export const compareVersion = (v1: string, v2: string) => { - return compare(parse(v1), parse(v2)) + return compare(parseVersion(v1), parseVersion(v2)) } export const isEqualOrLaterThanVersion = (baseVersion: string, targetVersion: string) => { - return greaterOrEqual(parse(baseVersion), parse(targetVersion)) + return greaterOrEqual(parseVersion(baseVersion), parseVersion(targetVersion)) } export const isEarlierThanVersion = (baseVersion: string, targetVersion: string) => { - return lessThan(parse(baseVersion), parse(targetVersion)) + return lessThan(parseVersion(baseVersion), parseVersion(targetVersion)) }