From f87dafa229edfa18ddebc5a76be966a7ee9a72e3 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 25 Mar 2026 16:16:52 +0800 Subject: [PATCH 1/2] fix: partner stack not recorded when not login (#34062) --- .../__tests__/cookie-recorder.spec.tsx | 45 +++++++++++++++++++ .../billing/partner-stack/cookie-recorder.tsx | 19 ++++++++ .../billing/partner-stack/use-ps-info.ts | 6 +-- web/app/layout.tsx | 2 + web/app/signin/page.tsx | 7 --- 5 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx create mode 100644 web/app/components/billing/partner-stack/cookie-recorder.tsx diff --git a/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx b/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx new file mode 100644 index 0000000000..1441653c9c --- /dev/null +++ b/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx @@ -0,0 +1,45 @@ +import { render } from '@testing-library/react' +import PartnerStackCookieRecorder from '../cookie-recorder' + +let isCloudEdition = true + +const saveOrUpdate = vi.fn() + +vi.mock('@/config', () => ({ + get IS_CLOUD_EDITION() { + return isCloudEdition + }, +})) + +vi.mock('../use-ps-info', () => ({ + default: () => ({ + saveOrUpdate, + }), +})) + +describe('PartnerStackCookieRecorder', () => { + beforeEach(() => { + vi.clearAllMocks() + isCloudEdition = true + }) + + it('should call saveOrUpdate once on mount when running in cloud edition', () => { + render() + + expect(saveOrUpdate).toHaveBeenCalledTimes(1) + }) + + it('should not call saveOrUpdate when not running in cloud edition', () => { + isCloudEdition = false + + render() + + expect(saveOrUpdate).not.toHaveBeenCalled() + }) + + it('should render null', () => { + const { container } = render() + + expect(container.innerHTML).toBe('') + }) +}) diff --git a/web/app/components/billing/partner-stack/cookie-recorder.tsx b/web/app/components/billing/partner-stack/cookie-recorder.tsx new file mode 100644 index 0000000000..3c75b2973c --- /dev/null +++ b/web/app/components/billing/partner-stack/cookie-recorder.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useEffect } from 'react' +import { IS_CLOUD_EDITION } from '@/config' +import usePSInfo from './use-ps-info' + +const PartnerStackCookieRecorder = () => { + const { saveOrUpdate } = usePSInfo() + + useEffect(() => { + if (!IS_CLOUD_EDITION) + return + saveOrUpdate() + }, []) + + return null +} + +export default PartnerStackCookieRecorder diff --git a/web/app/components/billing/partner-stack/use-ps-info.ts b/web/app/components/billing/partner-stack/use-ps-info.ts index 7c45d7ef87..5a83dec0e5 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.ts +++ b/web/app/components/billing/partner-stack/use-ps-info.ts @@ -24,7 +24,7 @@ const usePSInfo = () => { }] = useBoolean(false) const { mutateAsync } = useBindPartnerStackInfo() // Save to top domain. cloud.dify.ai => .dify.ai - const domain = globalThis.location.hostname.replace('cloud', '') + const domain = globalThis.location?.hostname.replace('cloud', '') const saveOrUpdate = useCallback(() => { if (!psPartnerKey || !psClickId) @@ -39,7 +39,7 @@ const usePSInfo = () => { path: '/', domain, }) - }, [psPartnerKey, psClickId, isPSChanged]) + }, [psPartnerKey, psClickId, isPSChanged, domain]) const bind = useCallback(async () => { if (psPartnerKey && psClickId && !hasBind) { @@ -59,7 +59,7 @@ const usePSInfo = () => { Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain }) setBind() } - }, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind]) + }, [psPartnerKey, psClickId, hasBind, domain, setBind, mutateAsync]) return { psPartnerKey, psClickId, diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f08ce9bb49..98cce27491 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -9,6 +9,7 @@ import { getLocaleOnServer } from '@/i18n-config/server' import { ToastProvider } from './components/base/toast' import { ToastHost } from './components/base/ui/toast' import { TooltipProvider } from './components/base/ui/tooltip' +import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder' import { AgentationLoader } from './components/devtools/agentation-loader' import { ReactScanLoader } from './components/devtools/react-scan/loader' import { I18nServerProvider } from './components/provider/i18n-server' @@ -67,6 +68,7 @@ const LocaleLayout = async ({ + diff --git a/web/app/signin/page.tsx b/web/app/signin/page.tsx index 7fad92fe5d..3f893b12fa 100644 --- a/web/app/signin/page.tsx +++ b/web/app/signin/page.tsx @@ -1,18 +1,11 @@ 'use client' -import { useEffect } from 'react' import { useSearchParams } from '@/next/navigation' -import usePSInfo from '../components/billing/partner-stack/use-ps-info' import NormalForm from './normal-form' import OneMoreStep from './one-more-step' const SignIn = () => { const searchParams = useSearchParams() const step = searchParams.get('step') - const { saveOrUpdate } = usePSInfo() - - useEffect(() => { - saveOrUpdate() - }, []) if (step === 'next') return From 7fbb1c96db4d95e457d0259ed85246c926e0a6bc Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 25 Mar 2026 17:21:48 +0800 Subject: [PATCH 2/2] feat(workflow): add selection context menu helpers and integrate with context menu component (#34013) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: lif <1835304752@qq.com> Co-authored-by: hjlarry Co-authored-by: Stephen Zhou Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Co-authored-by: Desel72 Co-authored-by: Renzo <170978465+RenzoMXD@users.noreply.github.com> Co-authored-by: Krishna Chaitanya Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web/__tests__/check-i18n.test.ts | 4 +- .../__tests__/candidate-node-main.spec.tsx | 260 +++++++ .../workflow/__tests__/custom-edge.spec.tsx | 235 ++++++ .../__tests__/node-contextmenu.spec.tsx | 114 +++ .../__tests__/panel-contextmenu.spec.tsx | 151 ++++ .../__tests__/selection-contextmenu.spec.tsx | 275 +++++++ .../update-dsl-modal.helpers.spec.ts | 79 ++ .../__tests__/update-dsl-modal.spec.tsx | 365 ++++++++++ .../help-line/__tests__/index.spec.tsx | 61 ++ .../hooks/__tests__/use-config-vision.spec.ts | 171 +++++ .../use-dynamic-test-run-options.spec.tsx | 146 ++++ .../_base/__tests__/node-sections.spec.tsx | 135 ++++ .../_base/__tests__/node.helpers.spec.ts | 34 + .../nodes/_base/__tests__/node.spec.tsx | 218 ++++++ .../use-node-resize-observer.spec.tsx | 55 ++ .../form-input-item.branches.spec.tsx | 410 +++++++++++ .../__tests__/form-input-item.helpers.spec.ts | 166 +++++ .../form-input-item.sections.spec.tsx | 60 ++ .../__tests__/form-input-item.spec.tsx | 148 ++++ .../before-run-form/__tests__/helpers.spec.ts | 115 +++ .../before-run-form/__tests__/index.spec.tsx | 226 ++++++ .../components/before-run-form/helpers.ts | 105 +++ .../components/before-run-form/index.tsx | 95 +-- .../components/form-input-item.helpers.ts | 259 +++++++ .../components/form-input-item.sections.tsx | 129 ++++ .../_base/components/form-input-item.tsx | 383 +++------- .../var-reference-picker.branches.spec.tsx | 226 ++++++ .../var-reference-picker.helpers.spec.ts | 236 ++++++ .../__tests__/var-reference-picker.spec.tsx | 140 ++++ .../var-reference-picker.trigger.spec.tsx | 176 +++++ .../var-reference-vars.helpers.spec.ts | 84 +++ .../__tests__/var-reference-vars.spec.tsx | 226 ++++++ .../variable/var-reference-picker.helpers.ts | 221 ++++++ .../variable/var-reference-picker.trigger.tsx | 315 ++++++++ .../variable/var-reference-picker.tsx | 477 ++++-------- .../variable/var-reference-vars.helpers.ts | 100 +++ .../variable/var-reference-vars.tsx | 90 +-- .../workflow-panel/__tests__/helpers.spec.tsx | 90 +++ .../workflow-panel/__tests__/index.spec.tsx | 687 +++++++++++++++--- .../components/workflow-panel/helpers.tsx | 80 ++ .../_base/components/workflow-panel/index.tsx | 75 +- .../last-run/__tests__/index.spec.tsx | 235 ++++++ .../workflow/nodes/_base/node-sections.tsx | 94 +++ .../workflow/nodes/_base/node.helpers.tsx | 32 + .../components/workflow/nodes/_base/node.tsx | 168 ++--- .../nodes/_base/use-node-resize-observer.ts | 30 + .../hooks/__tests__/use-config.spec.ts | 139 ++++ .../__tests__/button-style-dropdown.spec.tsx | 149 ++++ .../__tests__/form-content-preview.spec.tsx | 135 ++++ .../__tests__/form-content.spec.tsx | 258 +++++++ .../components/__tests__/timeout.spec.tsx | 77 ++ .../components/__tests__/user-action.spec.tsx | 146 ++++ .../delivery-method/__tests__/index.spec.tsx | 150 ++++ .../recipient/__tests__/index.spec.tsx | 156 ++++ .../hooks/__tests__/use-config.spec.ts | 156 ++++ .../hooks/__tests__/use-form-content.spec.ts | 112 +++ .../use-single-run-form-params.spec.ts | 234 ++++++ .../iteration/__tests__/use-config.spec.ts | 173 +++++ .../use-single-run-form-params.spec.ts | 168 +++++ .../nodes/start/__tests__/use-config.spec.ts | 245 +++++++ .../__tests__/generic-table.spec.tsx | 2 +- .../variable-assigner/__tests__/hooks.spec.ts | 244 +++++++ .../__tests__/integration.spec.tsx | 8 +- .../use-variable-modal-state.spec.ts | 195 +++++ .../__tests__/variable-modal.helpers.spec.ts | 123 ++++ .../__tests__/variable-modal.spec.tsx | 198 +++++ .../components/use-variable-modal-state.ts | 228 ++++++ .../components/variable-modal.helpers.ts | 170 +++++ .../components/variable-modal.sections.tsx | 217 ++++++ .../components/variable-modal.tsx | 456 +++--------- .../workflow/run/__tests__/hooks.spec.ts | 127 ++++ .../run/__tests__/result-panel.spec.tsx | 356 +++++++++ .../run/__tests__/tracing-panel.spec.tsx | 199 +++++ .../workflow/run/get-hovered-parallel-id.ts | 10 + .../components/workflow/run/tracing-panel.tsx | 26 +- .../utils/format-log/__tests__/index.spec.ts | 199 +++++ .../workflow/selection-contextmenu.tsx | 634 ++++++++-------- .../workflow/update-dsl-modal.helpers.ts | 110 +++ .../components/workflow/update-dsl-modal.tsx | 145 ++-- .../__tests__/value-content-sections.spec.tsx | 143 ++++ .../value-content.helpers.branches.spec.ts | 48 ++ .../__tests__/value-content.helpers.spec.ts | 80 ++ .../__tests__/value-content.spec.tsx | 410 +++++++++++ .../value-content-sections.tsx | 190 +++++ .../variable-inspect/value-content.helpers.ts | 77 ++ .../variable-inspect/value-content.tsx | 239 ++---- web/eslint-suppressions.json | 48 +- 87 files changed, 13256 insertions(+), 2105 deletions(-) create mode 100644 web/app/components/workflow/__tests__/candidate-node-main.spec.tsx create mode 100644 web/app/components/workflow/__tests__/custom-edge.spec.tsx create mode 100644 web/app/components/workflow/__tests__/node-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts create mode 100644 web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx create mode 100644 web/app/components/workflow/help-line/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/node-sections.tsx create mode 100644 web/app/components/workflow/nodes/_base/node.helpers.tsx create mode 100644 web/app/components/workflow/nodes/_base/use-node-resize-observer.ts create mode 100644 web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts create mode 100644 web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts create mode 100644 web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx create mode 100644 web/app/components/workflow/run/__tests__/hooks.spec.ts create mode 100644 web/app/components/workflow/run/__tests__/result-panel.spec.tsx create mode 100644 web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx create mode 100644 web/app/components/workflow/run/get-hovered-parallel-id.ts create mode 100644 web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts create mode 100644 web/app/components/workflow/update-dsl-modal.helpers.ts create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.branches.spec.ts create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.spec.ts create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.spec.tsx create mode 100644 web/app/components/workflow/variable-inspect/value-content-sections.tsx create mode 100644 web/app/components/workflow/variable-inspect/value-content.helpers.ts diff --git a/web/__tests__/check-i18n.test.ts b/web/__tests__/check-i18n.test.ts index de78ae997e..9e9b3d7168 100644 --- a/web/__tests__/check-i18n.test.ts +++ b/web/__tests__/check-i18n.test.ts @@ -774,7 +774,7 @@ export default translation` const endTime = Date.now() expect(keys.length).toBe(1000) - expect(endTime - startTime).toBeLessThan(1000) // Should complete in under 1 second + expect(endTime - startTime).toBeLessThan(10000) }) it('should handle multiple translation files concurrently', async () => { @@ -796,7 +796,7 @@ export default translation` const endTime = Date.now() expect(keys.length).toBe(20) // 10 files * 2 keys each - expect(endTime - startTime).toBeLessThan(500) + expect(endTime - startTime).toBeLessThan(10000) }) }) diff --git a/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx new file mode 100644 index 0000000000..61e5410aac --- /dev/null +++ b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx @@ -0,0 +1,260 @@ +import { render, screen } from '@testing-library/react' +import CandidateNodeMain from '../candidate-node-main' +import { CUSTOM_NODE } from '../constants' +import { CUSTOM_NOTE_NODE } from '../note-node/constants' +import { BlockEnum } from '../types' +import { createNode } from './fixtures' + +const mockUseEventListener = vi.hoisted(() => vi.fn()) +const mockUseStoreApi = vi.hoisted(() => vi.fn()) +const mockUseReactFlow = vi.hoisted(() => vi.fn()) +const mockUseViewport = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockUseWorkflowStore = vi.hoisted(() => vi.fn()) +const mockUseHooks = vi.hoisted(() => vi.fn()) +const mockCustomNode = vi.hoisted(() => vi.fn()) +const mockCustomNoteNode = vi.hoisted(() => vi.fn()) +const mockGetIterationStartNode = vi.hoisted(() => vi.fn()) +const mockGetLoopStartNode = vi.hoisted(() => vi.fn()) + +vi.mock('ahooks', () => ({ + useEventListener: (...args: unknown[]) => mockUseEventListener(...args), +})) + +vi.mock('reactflow', () => ({ + useStoreApi: () => mockUseStoreApi(), + useReactFlow: () => mockUseReactFlow(), + useViewport: () => mockUseViewport(), + Position: { + Left: 'left', + Right: 'right', + }, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { mousePosition: { + pageX: number + pageY: number + elementX: number + elementY: number + } }) => unknown) => mockUseStore(selector), + useWorkflowStore: () => mockUseWorkflowStore(), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesInteractions: () => mockUseHooks().useNodesInteractions(), + useNodesSyncDraft: () => mockUseHooks().useNodesSyncDraft(), + useWorkflowHistory: () => mockUseHooks().useWorkflowHistory(), + useAutoGenerateWebhookUrl: () => mockUseHooks().useAutoGenerateWebhookUrl(), + WorkflowHistoryEvent: { + NodeAdd: 'NodeAdd', + NoteAdd: 'NoteAdd', + }, +})) + +vi.mock('@/app/components/workflow/nodes', () => ({ + __esModule: true, + default: (props: { id: string }) => { + mockCustomNode(props) + return
{props.id}
+ }, +})) + +vi.mock('@/app/components/workflow/note-node', () => ({ + __esModule: true, + default: (props: { id: string }) => { + mockCustomNoteNode(props) + return
{props.id}
+ }, +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + getIterationStartNode: (...args: unknown[]) => mockGetIterationStartNode(...args), + getLoopStartNode: (...args: unknown[]) => mockGetLoopStartNode(...args), +})) + +describe('CandidateNodeMain', () => { + const mockSetNodes = vi.fn() + const mockHandleNodeSelect = vi.fn() + const mockSaveStateToHistory = vi.fn() + const mockHandleSyncWorkflowDraft = vi.fn() + const mockAutoGenerateWebhookUrl = vi.fn() + const mockWorkflowStoreSetState = vi.fn() + const createNodesInteractions = () => ({ + handleNodeSelect: mockHandleNodeSelect, + }) + const createWorkflowHistory = () => ({ + saveStateToHistory: mockSaveStateToHistory, + }) + const createNodesSyncDraft = () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }) + const createAutoGenerateWebhookUrl = () => mockAutoGenerateWebhookUrl + const eventHandlers: Partial void }) => void>> = {} + let nodes = [createNode({ id: 'existing-node' })] + + beforeEach(() => { + vi.clearAllMocks() + nodes = [createNode({ id: 'existing-node' })] + eventHandlers.click = undefined + eventHandlers.contextmenu = undefined + + mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu', handler: (event: { preventDefault: () => void }) => void) => { + eventHandlers[event] = handler + }) + mockUseStoreApi.mockReturnValue({ + getState: () => ({ + getNodes: () => nodes, + setNodes: mockSetNodes, + }), + }) + mockUseReactFlow.mockReturnValue({ + screenToFlowPosition: ({ x, y }: { x: number, y: number }) => ({ x: x + 10, y: y + 20 }), + }) + mockUseViewport.mockReturnValue({ zoom: 1.5 }) + mockUseStore.mockImplementation((selector: (state: { mousePosition: { + pageX: number + pageY: number + elementX: number + elementY: number + } }) => unknown) => selector({ + mousePosition: { + pageX: 100, + pageY: 200, + elementX: 30, + elementY: 40, + }, + })) + mockUseWorkflowStore.mockReturnValue({ + setState: mockWorkflowStoreSetState, + }) + mockUseHooks.mockReturnValue({ + useNodesInteractions: createNodesInteractions, + useWorkflowHistory: createWorkflowHistory, + useNodesSyncDraft: createNodesSyncDraft, + useAutoGenerateWebhookUrl: createAutoGenerateWebhookUrl, + }) + mockHandleSyncWorkflowDraft.mockImplementation((_isSync: boolean, _force: boolean, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + mockGetIterationStartNode.mockReturnValue(createNode({ id: 'iteration-start' })) + mockGetLoopStartNode.mockReturnValue(createNode({ id: 'loop-start' })) + }) + + it('should render the candidate node and commit a webhook node on click', () => { + const candidateNode = createNode({ + id: 'candidate-webhook', + type: CUSTOM_NODE, + data: { + type: BlockEnum.TriggerWebhook, + title: 'Webhook Candidate', + _isCandidate: true, + }, + }) + + const { container } = render() + + expect(screen.getByTestId('candidate-custom-node')).toHaveTextContent('candidate-webhook') + expect(container.firstChild).toHaveStyle({ + left: '30px', + top: '40px', + transform: 'scale(1.5)', + }) + + eventHandlers.click?.({ preventDefault: vi.fn() }) + + expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ id: 'existing-node' }), + expect.objectContaining({ + id: 'candidate-webhook', + position: { x: 110, y: 220 }, + data: expect.objectContaining({ _isCandidate: false }), + }), + ])) + expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeAdd', { nodeId: 'candidate-webhook' }) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined }) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('candidate-webhook') + expect(mockHandleNodeSelect).not.toHaveBeenCalled() + }) + + it('should save note candidates as notes and select the inserted note', () => { + const candidateNode = createNode({ + id: 'candidate-note', + type: CUSTOM_NOTE_NODE, + data: { + type: BlockEnum.Code, + title: 'Note Candidate', + _isCandidate: true, + }, + }) + + render() + + expect(screen.getByTestId('candidate-note-node')).toHaveTextContent('candidate-note') + + eventHandlers.click?.({ preventDefault: vi.fn() }) + + expect(mockSaveStateToHistory).toHaveBeenCalledWith('NoteAdd', { nodeId: 'candidate-note' }) + expect(mockHandleNodeSelect).toHaveBeenCalledWith('candidate-note') + }) + + it('should append iteration and loop start helper nodes for control-flow candidates', () => { + const iterationNode = createNode({ + id: 'candidate-iteration', + type: CUSTOM_NODE, + data: { + type: BlockEnum.Iteration, + title: 'Iteration Candidate', + _isCandidate: true, + }, + }) + const loopNode = createNode({ + id: 'candidate-loop', + type: CUSTOM_NODE, + data: { + type: BlockEnum.Loop, + title: 'Loop Candidate', + _isCandidate: true, + }, + }) + + const { rerender } = render() + + eventHandlers.click?.({ preventDefault: vi.fn() }) + expect(mockGetIterationStartNode).toHaveBeenCalledWith('candidate-iteration') + expect(mockSetNodes.mock.calls[0][0]).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'candidate-iteration' }), + expect.objectContaining({ id: 'iteration-start' }), + ])) + + rerender() + eventHandlers.click?.({ preventDefault: vi.fn() }) + + expect(mockGetLoopStartNode).toHaveBeenCalledWith('candidate-loop') + expect(mockSetNodes.mock.calls[1][0]).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'candidate-loop' }), + expect.objectContaining({ id: 'loop-start' }), + ])) + }) + + it('should clear the candidate node on contextmenu', () => { + const candidateNode = createNode({ + id: 'candidate-context', + type: CUSTOM_NODE, + data: { + type: BlockEnum.Code, + title: 'Context Candidate', + _isCandidate: true, + }, + }) + + render() + + eventHandlers.contextmenu?.({ preventDefault: vi.fn() }) + + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined }) + }) +}) diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx new file mode 100644 index 0000000000..f8ff9a1a0e --- /dev/null +++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx @@ -0,0 +1,235 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { Position } from 'reactflow' +import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' +import CustomEdge from '../custom-edge' +import { BlockEnum, NodeRunningStatus } from '../types' + +const mockUseAvailableBlocks = vi.hoisted(() => vi.fn()) +const mockUseNodesInteractions = vi.hoisted(() => vi.fn()) +const mockBlockSelector = vi.hoisted(() => vi.fn()) +const mockGradientRender = vi.hoisted(() => vi.fn()) + +vi.mock('reactflow', () => ({ + BaseEdge: (props: { + id: string + path: string + style: { + stroke: string + strokeWidth: number + opacity: number + strokeDasharray?: string + } + }) => ( +
+ ), + EdgeLabelRenderer: ({ children }: { children?: ReactNode }) =>
{children}
, + getBezierPath: () => ['M 0 0', 24, 48], + Position: { + Right: 'right', + Left: 'left', + }, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useAvailableBlocks: (...args: unknown[]) => mockUseAvailableBlocks(...args), + useNodesInteractions: () => mockUseNodesInteractions(), +})) + +vi.mock('@/app/components/workflow/block-selector', () => ({ + __esModule: true, + default: (props: { + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (nodeType: string, pluginDefaultValue?: Record) => void + availableBlocksTypes: string[] + triggerClassName?: () => string + }) => { + mockBlockSelector(props) + return ( + + ) + }, +})) + +vi.mock('@/app/components/workflow/custom-edge-linear-gradient-render', () => ({ + __esModule: true, + default: (props: { + id: string + startColor: string + stopColor: string + }) => { + mockGradientRender(props) + return
{props.id}
+ }, +})) + +describe('CustomEdge', () => { + const mockHandleNodeAdd = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockUseNodesInteractions.mockReturnValue({ + handleNodeAdd: mockHandleNodeAdd, + }) + mockUseAvailableBlocks.mockImplementation((nodeType: BlockEnum) => { + if (nodeType === BlockEnum.Code) + return { availablePrevBlocks: ['code', 'llm'] } + + return { availableNextBlocks: ['llm', 'tool'] } + }) + }) + + it('should render a gradient edge and insert a node between the source and target', () => { + render( + , + ) + + expect(screen.getByTestId('edge-gradient')).toHaveTextContent('edge-1') + expect(mockGradientRender).toHaveBeenCalledWith(expect.objectContaining({ + id: 'edge-1', + startColor: 'var(--color-workflow-link-line-success-handle)', + stopColor: 'var(--color-workflow-link-line-error-handle)', + })) + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'url(#edge-1)') + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-opacity', '0.3') + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-dasharray', '8 8') + expect(screen.getByTestId('block-selector')).toHaveTextContent('llm') + expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({ + transform: 'translate(-50%, -50%) translate(24px, 48px)', + opacity: '0.7', + }) + + fireEvent.click(screen.getByTestId('block-selector')) + + expect(mockHandleNodeAdd).toHaveBeenCalledWith( + { + nodeType: 'llm', + pluginDefaultValue: { provider: 'openai' }, + }, + { + prevNodeId: 'source-node', + prevNodeSourceHandle: 'source', + nextNodeId: 'target-node', + nextNodeTargetHandle: 'target', + }, + ) + }) + + it('should prefer the running stroke color when the edge is selected', () => { + render( + , + ) + + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-handle)') + }) + + it('should use the fail-branch running color while the connected node is hovering', () => { + render( + , + ) + + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-failure-handle)') + }) + + it('should fall back to the default edge color when no highlight state is active', () => { + render( + , + ) + + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)') + expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all') + }) +}) diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx new file mode 100644 index 0000000000..7418b7f313 --- /dev/null +++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx @@ -0,0 +1,114 @@ +import type { Node } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import NodeContextmenu from '../node-contextmenu' + +const mockUseClickAway = vi.hoisted(() => vi.fn()) +const mockUseNodes = vi.hoisted(() => vi.fn()) +const mockUsePanelInteractions = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockPanelOperatorPopup = vi.hoisted(() => vi.fn()) + +vi.mock('ahooks', () => ({ + useClickAway: (...args: unknown[]) => mockUseClickAway(...args), +})) + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + __esModule: true, + default: () => mockUseNodes(), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + usePanelInteractions: () => mockUsePanelInteractions(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({ + __esModule: true, + default: (props: { + id: string + data: Node['data'] + showHelpLink: boolean + onClosePopup: () => void + }) => { + mockPanelOperatorPopup(props) + return ( + + ) + }, +})) + +describe('NodeContextmenu', () => { + const mockHandleNodeContextmenuCancel = vi.fn() + let nodeMenu: { nodeId: string, left: number, top: number } | undefined + let nodes: Node[] + let clickAwayHandler: (() => void) | undefined + + beforeEach(() => { + vi.clearAllMocks() + nodeMenu = undefined + nodes = [{ + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + title: 'Node 1', + desc: '', + type: 'code' as never, + }, + } as Node] + clickAwayHandler = undefined + + mockUseClickAway.mockImplementation((handler: () => void) => { + clickAwayHandler = handler + }) + mockUseNodes.mockImplementation(() => nodes) + mockUsePanelInteractions.mockReturnValue({ + handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel, + }) + mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu })) + }) + + it('should stay hidden when the node menu is absent', () => { + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(mockPanelOperatorPopup).not.toHaveBeenCalled() + }) + + it('should stay hidden when the referenced node cannot be found', () => { + nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 } + + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(mockPanelOperatorPopup).not.toHaveBeenCalled() + }) + + it('should render the popup at the stored position and close on popup/click-away actions', () => { + nodeMenu = { nodeId: 'node-1', left: 80, top: 120 } + const { container } = render() + + expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1') + expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({ + id: 'node-1', + data: expect.objectContaining({ title: 'Node 1' }), + showHelpLink: true, + })) + expect(container.firstChild).toHaveStyle({ + left: '80px', + top: '120px', + }) + + fireEvent.click(screen.getByRole('button')) + clickAwayHandler?.() + + expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx new file mode 100644 index 0000000000..914c1be617 --- /dev/null +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -0,0 +1,151 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import PanelContextmenu from '../panel-contextmenu' + +const mockUseClickAway = vi.hoisted(() => vi.fn()) +const mockUseTranslation = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockUseNodesInteractions = vi.hoisted(() => vi.fn()) +const mockUsePanelInteractions = vi.hoisted(() => vi.fn()) +const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn()) +const mockUseOperator = vi.hoisted(() => vi.fn()) +const mockUseDSL = vi.hoisted(() => vi.fn()) + +vi.mock('ahooks', () => ({ + useClickAway: (...args: unknown[]) => mockUseClickAway(...args), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + panelMenu?: { left: number, top: number } + clipboardElements: unknown[] + setShowImportDSLModal: (visible: boolean) => void + }) => unknown) => mockUseStore(selector), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesInteractions: () => mockUseNodesInteractions(), + usePanelInteractions: () => mockUsePanelInteractions(), + useWorkflowStartRun: () => mockUseWorkflowStartRun(), + useDSL: () => mockUseDSL(), +})) + +vi.mock('@/app/components/workflow/operator/hooks', () => ({ + useOperator: () => mockUseOperator(), +})) + +vi.mock('@/app/components/workflow/operator/add-block', () => ({ + __esModule: true, + default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => ( +
{renderTrigger()}
+ ), +})) + +vi.mock('@/app/components/base/divider', () => ({ + __esModule: true, + default: ({ className }: { className?: string }) =>
, +})) + +vi.mock('@/app/components/workflow/shortcuts-name', () => ({ + __esModule: true, + default: ({ keys }: { keys: string[] }) => {keys.join('+')}, +})) + +describe('PanelContextmenu', () => { + const mockHandleNodesPaste = vi.fn() + const mockHandlePaneContextmenuCancel = vi.fn() + const mockHandleStartWorkflowRun = vi.fn() + const mockHandleAddNote = vi.fn() + const mockExportCheck = vi.fn() + const mockSetShowImportDSLModal = vi.fn() + let panelMenu: { left: number, top: number } | undefined + let clipboardElements: unknown[] + let clickAwayHandler: (() => void) | undefined + + beforeEach(() => { + vi.clearAllMocks() + panelMenu = undefined + clipboardElements = [] + clickAwayHandler = undefined + + mockUseClickAway.mockImplementation((handler: () => void) => { + clickAwayHandler = handler + }) + mockUseTranslation.mockReturnValue({ + t: (key: string) => key, + }) + mockUseStore.mockImplementation((selector: (state: { + panelMenu?: { left: number, top: number } + clipboardElements: unknown[] + setShowImportDSLModal: (visible: boolean) => void + }) => unknown) => selector({ + panelMenu, + clipboardElements, + setShowImportDSLModal: mockSetShowImportDSLModal, + })) + mockUseNodesInteractions.mockReturnValue({ + handleNodesPaste: mockHandleNodesPaste, + }) + mockUsePanelInteractions.mockReturnValue({ + handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, + }) + mockUseWorkflowStartRun.mockReturnValue({ + handleStartWorkflowRun: mockHandleStartWorkflowRun, + }) + mockUseOperator.mockReturnValue({ + handleAddNote: mockHandleAddNote, + }) + mockUseDSL.mockReturnValue({ + exportCheck: mockExportCheck, + }) + }) + + it('should stay hidden when the panel menu is absent', () => { + render() + + expect(screen.queryByTestId('add-block')).not.toBeInTheDocument() + }) + + it('should keep paste disabled when the clipboard is empty', () => { + panelMenu = { left: 24, top: 48 } + + render() + + fireEvent.click(screen.getByText('common.pasteHere')) + + expect(mockHandleNodesPaste).not.toHaveBeenCalled() + expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled() + }) + + it('should render actions, position the menu, and execute each action', () => { + panelMenu = { left: 24, top: 48 } + clipboardElements = [{ id: 'copied-node' }] + const { container } = render() + + expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock') + expect(screen.getByTestId('shortcut-alt-r')).toHaveTextContent('alt+r') + expect(screen.getByTestId('shortcut-ctrl-v')).toHaveTextContent('ctrl+v') + expect(container.firstChild).toHaveStyle({ + left: '24px', + top: '48px', + }) + + fireEvent.click(screen.getByText('nodes.note.addNote')) + fireEvent.click(screen.getByText('common.run')) + fireEvent.click(screen.getByText('common.pasteHere')) + fireEvent.click(screen.getByText('export')) + fireEvent.click(screen.getByText('common.importDSL')) + clickAwayHandler?.() + + expect(mockHandleAddNote).toHaveBeenCalledTimes(1) + expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1) + expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1) + expect(mockExportCheck).toHaveBeenCalledTimes(1) + expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true) + expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4) + }) +}) diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx new file mode 100644 index 0000000000..247184349d --- /dev/null +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -0,0 +1,275 @@ +import type { Edge, Node } from '../types' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { useEffect } from 'react' +import { useNodes } from 'reactflow' +import SelectionContextmenu from '../selection-contextmenu' +import { useWorkflowHistoryStore } from '../workflow-history-store' +import { createEdge, createNode } from './fixtures' +import { renderWorkflowFlowComponent } from './workflow-test-env' + +let latestNodes: Node[] = [] +let latestHistoryEvent: string | undefined +const mockGetNodesReadOnly = vi.fn() + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks') + return { + ...actual, + useNodesReadOnly: () => ({ + getNodesReadOnly: mockGetNodesReadOnly, + }), + } +}) + +const RuntimeProbe = () => { + latestNodes = useNodes() as Node[] + const { store } = useWorkflowHistoryStore() + + useEffect(() => { + latestHistoryEvent = store.getState().workflowHistoryEvent + return store.subscribe((state) => { + latestHistoryEvent = state.workflowHistoryEvent + }) + }, [store]) + + return null +} + +const hooksStoreProps = { + doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), +} + +const renderSelectionMenu = (options?: { + nodes?: Node[] + edges?: Edge[] + initialStoreState?: Record +}) => { + latestNodes = [] + latestHistoryEvent = undefined + + const nodes = options?.nodes ?? [] + const edges = options?.edges ?? [] + + return renderWorkflowFlowComponent( +
+ + +
, + { + nodes, + edges, + hooksStoreProps, + historyStore: { nodes, edges }, + initialStoreState: options?.initialStoreState, + reactFlowProps: { fitView: false }, + }, + ) +} + +describe('SelectionContextmenu', () => { + beforeEach(() => { + vi.clearAllMocks() + latestNodes = [] + latestHistoryEvent = undefined + mockGetNodesReadOnly.mockReset() + mockGetNodesReadOnly.mockReturnValue(false) + }) + + it('should not render when selectionMenu is absent', () => { + renderSelectionMenu() + + expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument() + }) + + it('should keep the menu inside the workflow container bounds', () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), + ] + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 780, top: 590 } }) + }) + + const menu = screen.getByTestId('selection-contextmenu') + expect(menu).toHaveStyle({ left: '540px', top: '210px' }) + }) + + it('should close itself when only one node is selected', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 120, top: 120 } }) + }) + + await waitFor(() => { + expect(store.getState().selectionMenu).toBeUndefined() + }) + }) + + it('should align selected nodes to the left and save history', async () => { + vi.useFakeTimers() + const nodes = [ + createNode({ id: 'n1', selected: true, position: { x: 20, y: 40 }, width: 40, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 90 }, width: 60, height: 30 }), + ] + + const { store } = renderSelectionMenu({ + nodes, + edges: [createEdge({ source: 'n1', target: 'n2' })], + initialStoreState: { + helpLineHorizontal: { y: 10 } as never, + helpLineVertical: { x: 10 } as never, + }, + }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(20) + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(20) + expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().helpLineHorizontal).toBeUndefined() + expect(store.getState().helpLineVertical).toBeUndefined() + + act(() => { + store.getState().flushPendingSync() + vi.advanceTimersByTime(600) + }) + + expect(hooksStoreProps.doSyncWorkflowDraft).toHaveBeenCalled() + expect(latestHistoryEvent).toBe('NodeDragStop') + vi.useRealTimers() + }) + + it('should distribute selected nodes horizontally', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 100, y: 20 }, width: 20, height: 20 }), + createNode({ id: 'n3', selected: true, position: { x: 300, y: 30 }, width: 20, height: 20 }), + ] + + const { store } = renderSelectionMenu({ + nodes, + }) + + act(() => { + store.setState({ selectionMenu: { left: 160, top: 120 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal')) + + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(150) + }) + + it('should ignore child nodes when the selected container is aligned', async () => { + const nodes = [ + createNode({ + id: 'container', + selected: true, + position: { x: 200, y: 0 }, + width: 100, + height: 80, + data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, + }), + createNode({ + id: 'child', + selected: true, + position: { x: 210, y: 10 }, + width: 30, + height: 20, + }), + createNode({ + id: 'other', + selected: true, + position: { x: 40, y: 60 }, + width: 40, + height: 20, + }), + ] + + const { store } = renderSelectionMenu({ + nodes, + }) + + act(() => { + store.setState({ selectionMenu: { left: 180, top: 120 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(40) + expect(latestNodes.find(node => node.id === 'other')?.position.x).toBe(40) + expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(210) + }) + + it('should cancel when align bounds cannot be resolved', () => { + const nodes = [ + createNode({ id: 'n1', selected: true }), + createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 } }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(store.getState().selectionMenu).toBeUndefined() + }) + + it('should cancel without aligning when nodes are read only', () => { + mockGetNodesReadOnly.mockReturnValue(true) + const nodes = [ + createNode({ id: 'n1', selected: true, width: 40, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(store.getState().selectionMenu).toBeUndefined() + expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0) + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80) + }) + + it('should cancel when alignable nodes shrink to one item', () => { + const nodes = [ + createNode({ + id: 'container', + selected: true, + width: 40, + height: 20, + data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, + }), + createNode({ id: 'child', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(store.getState().selectionMenu).toBeUndefined() + expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(0) + expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(80) + }) +}) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts new file mode 100644 index 0000000000..ac1cf67970 --- /dev/null +++ b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts @@ -0,0 +1,79 @@ +import { DSLImportStatus } from '@/models/app' +import { AppModeEnum } from '@/types/app' +import { BlockEnum } from '../types' +import { + getInvalidNodeTypes, + isImportCompleted, + normalizeWorkflowFeatures, + validateDSLContent, +} from '../update-dsl-modal.helpers' + +describe('update-dsl-modal helpers', () => { + describe('dsl validation', () => { + it('should reject advanced chat dsl content with disallowed trigger nodes', () => { + const content = ` +workflow: + graph: + nodes: + - data: + type: trigger-webhook +` + + expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false) + }) + + it('should reject malformed yaml and answer nodes in non-advanced mode', () => { + expect(validateDSLContent('[', AppModeEnum.CHAT)).toBe(false) + expect(validateDSLContent(` +workflow: + graph: + nodes: + - data: + type: answer +`, AppModeEnum.CHAT)).toBe(false) + }) + + it('should accept valid node types for advanced chat mode', () => { + expect(validateDSLContent(` +workflow: + graph: + nodes: + - data: + type: tool +`, AppModeEnum.ADVANCED_CHAT)).toBe(true) + }) + + it('should expose the invalid node sets per mode', () => { + expect(getInvalidNodeTypes(AppModeEnum.ADVANCED_CHAT)).toEqual( + expect.arrayContaining([BlockEnum.End, BlockEnum.TriggerWebhook]), + ) + expect(getInvalidNodeTypes(AppModeEnum.CHAT)).toEqual([BlockEnum.Answer]) + }) + }) + + describe('status and feature normalization', () => { + it('should treat completed statuses as successful imports', () => { + expect(isImportCompleted(DSLImportStatus.COMPLETED)).toBe(true) + expect(isImportCompleted(DSLImportStatus.COMPLETED_WITH_WARNINGS)).toBe(true) + expect(isImportCompleted(DSLImportStatus.PENDING)).toBe(false) + }) + + it('should normalize workflow features with defaults', () => { + const features = normalizeWorkflowFeatures({ + file_upload: { + image: { + enabled: true, + }, + }, + opening_statement: 'hello', + suggested_questions: ['what can you do?'], + }) + + expect(features.file.enabled).toBe(true) + expect(features.file.number_limits).toBe(3) + expect(features.opening.enabled).toBe(true) + expect(features.suggested).toEqual({ enabled: false }) + expect(features.text2speech).toEqual({ enabled: false }) + }) + }) +}) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx new file mode 100644 index 0000000000..82645f2028 --- /dev/null +++ b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx @@ -0,0 +1,365 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { EventEmitterValue } from '@/context/event-emitter' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { toast } from '@/app/components/base/ui/toast' +import { EventEmitterContext } from '@/context/event-emitter' +import { DSLImportStatus } from '@/models/app' +import UpdateDSLModal from '../update-dsl-modal' + +class MockFileReader { + onload: ((this: FileReader, event: ProgressEvent) => void) | null = null + + readAsText(_file: Blob) { + const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: tool\n' } } as unknown as ProgressEvent + this.onload?.call(this as unknown as FileReader, event) + } +} + +vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) +const mockEmit = vi.fn() + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})) + +const mockImportDSL = vi.fn() +const mockImportDSLConfirm = vi.fn() +vi.mock('@/service/apps', () => ({ + importDSL: (payload: unknown) => mockImportDSL(payload), + importDSLConfirm: (payload: unknown) => mockImportDSLConfirm(payload), +})) + +const mockFetchWorkflowDraft = vi.fn() +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: (path: string) => mockFetchWorkflowDraft(path), +})) + +const mockHandleCheckPluginDependencies = vi.fn() +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: mockHandleCheckPluginDependencies, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { id: string, mode: string } }) => unknown) => selector({ + appDetail: { + id: 'app-1', + mode: 'chat', + }, + }), +})) + +vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ + default: ({ updateFile }: { updateFile: (file?: File) => void }) => ( + updateFile(event.target.files?.[0])} + /> + ), +})) + +describe('UpdateDSLModal', () => { + const mockToastError = vi.mocked(toast.error) + const defaultProps = { + onCancel: vi.fn(), + onBackup: vi.fn(), + onImport: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + mockFetchWorkflowDraft.mockResolvedValue({ + graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, + features: {}, + hash: 'hash-1', + conversation_variables: [], + environment_variables: [], + }) + mockImportDSL.mockResolvedValue({ + id: 'import-1', + status: DSLImportStatus.COMPLETED, + app_id: 'app-1', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + app_id: 'app-1', + }) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + }) + + const renderModal = (props = defaultProps) => { + const eventEmitter = { emit: mockEmit } as unknown as EventEmitter + + return render( + + + , + ) + } + + it('should keep import disabled until a file is selected', () => { + renderModal() + + expect(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })).toBeDisabled() + }) + + it('should call backup handler from the warning area', () => { + renderModal() + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.backupCurrentDraft' })) + + expect(defaultProps.onBackup).toHaveBeenCalledTimes(1) + }) + + it('should import a valid file and emit workflow update payload', async () => { + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalledWith(expect.objectContaining({ + app_id: 'app-1', + yaml_content: expect.stringContaining('workflow:'), + })) + }) + + expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'WORKFLOW_DATA_UPDATE', + })) + expect(defaultProps.onImport).toHaveBeenCalledTimes(1) + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1) + }) + + it('should show an error notification when import fails', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-1', + status: DSLImportStatus.FAILED, + app_id: 'app-1', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['invalid'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should open the version warning modal for pending imports and confirm them', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-2', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-2' }) + }) + }) + + it('should open the pending modal after the timeout and allow dismissing it', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-5', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Cancel' })).toBeInTheDocument() + }, { timeout: 1000 }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Cancel' })) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'app.newApp.Confirm' })).not.toBeInTheDocument() + }) + }) + + it('should show an error when the selected file content is invalid for the current app mode', async () => { + class InvalidDSLFileReader extends MockFileReader { + readAsText(_file: Blob) { + const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: answer\n' } } as unknown as ProgressEvent + this.onload?.call(this as unknown as FileReader, event) + } + } + + vi.stubGlobal('FileReader', InvalidDSLFileReader as unknown as typeof FileReader) + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + expect(mockImportDSL).not.toHaveBeenCalled() + + vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) + }) + + it('should show an error notification when import throws', async () => { + mockImportDSL.mockRejectedValue(new Error('boom')) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when completed import does not return an app id', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-3', + status: DSLImportStatus.COMPLETED, + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when confirming a pending import fails', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-4', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.FAILED, + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when confirming a pending import throws', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-6', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockRejectedValue(new Error('boom')) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when a confirmed pending import completes without an app id', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-7', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/help-line/__tests__/index.spec.tsx b/web/app/components/workflow/help-line/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f58c9c5d02 --- /dev/null +++ b/web/app/components/workflow/help-line/__tests__/index.spec.tsx @@ -0,0 +1,61 @@ +import { render } from '@testing-library/react' +import HelpLine from '../index' + +const mockUseViewport = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) + +vi.mock('reactflow', () => ({ + useViewport: () => mockUseViewport(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + helpLineHorizontal?: { top: number, left: number, width: number } + helpLineVertical?: { top: number, left: number, height: number } + }) => unknown) => mockUseStore(selector), +})) + +describe('HelpLine', () => { + let helpLineHorizontal: { top: number, left: number, width: number } | undefined + let helpLineVertical: { top: number, left: number, height: number } | undefined + + beforeEach(() => { + vi.clearAllMocks() + helpLineHorizontal = undefined + helpLineVertical = undefined + + mockUseViewport.mockReturnValue({ x: 10, y: 20, zoom: 2 }) + mockUseStore.mockImplementation((selector: (state: { + helpLineHorizontal?: { top: number, left: number, width: number } + helpLineVertical?: { top: number, left: number, height: number } + }) => unknown) => selector({ + helpLineHorizontal, + helpLineVertical, + })) + }) + + it('should render nothing when both help lines are absent', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should render the horizontal and vertical guide lines using viewport offsets and zoom', () => { + helpLineHorizontal = { top: 30, left: 40, width: 50 } + helpLineVertical = { top: 60, left: 70, height: 80 } + + const { container } = render() + const [horizontal, vertical] = Array.from(container.querySelectorAll('div')) + + expect(horizontal).toHaveStyle({ + top: '80px', + left: '90px', + width: '100px', + }) + expect(vertical).toHaveStyle({ + top: '140px', + left: '150px', + height: '160px', + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts new file mode 100644 index 0000000000..5811f14a60 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts @@ -0,0 +1,171 @@ +import type { ModelConfig, VisionSetting } from '@/app/components/workflow/types' +import { act, renderHook } from '@testing-library/react' +import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { Resolution } from '@/types/app' +import useConfigVision from '../use-config-vision' + +const mockUseTextGenerationCurrentProviderAndModelAndModelList = vi.hoisted(() => vi.fn()) +const mockUseIsChatMode = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) => + mockUseTextGenerationCurrentProviderAndModelAndModelList(...args), +})) + +vi.mock('../use-workflow', () => ({ + useIsChatMode: () => mockUseIsChatMode(), +})) + +const createModel = (overrides: Partial = {}): ModelConfig => ({ + provider: 'openai', + name: 'gpt-4o', + mode: 'chat', + completion_params: [], + ...overrides, +}) + +const createVisionPayload = (overrides: Partial<{ enabled: boolean, configs?: VisionSetting }> = {}) => ({ + enabled: false, + ...overrides, +}) + +describe('useConfigVision', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseIsChatMode.mockReturnValue(false) + mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({ + currentModel: { + features: [], + }, + }) + }) + + it('should expose vision capability and enable default chat configs for vision models', () => { + const onChange = vi.fn() + mockUseIsChatMode.mockReturnValue(true) + mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({ + currentModel: { + features: [ModelFeatureEnum.vision], + }, + }) + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload(), + onChange, + })) + + expect(result.current.isVisionModel).toBe(true) + + act(() => { + result.current.handleVisionResolutionEnabledChange(true) + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + }, + }) + }) + + it('should clear configs when disabling vision resolution', () => { + const onChange = vi.fn() + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.low, + variable_selector: ['node', 'files'], + }, + }), + onChange, + })) + + act(() => { + result.current.handleVisionResolutionEnabledChange(false) + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: false, + }) + }) + + it('should update the resolution config payload directly', () => { + const onChange = vi.fn() + const config: VisionSetting = { + detail: Resolution.low, + variable_selector: ['upstream', 'images'], + } + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ enabled: true }), + onChange, + })) + + act(() => { + result.current.handleVisionResolutionChange(config) + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: true, + configs: config, + }) + }) + + it('should disable vision settings when the selected model is no longer a vision model', () => { + const onChange = vi.fn() + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + }, + }), + onChange, + })) + + act(() => { + result.current.handleModelChanged() + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: false, + }) + }) + + it('should reset enabled vision configs when the model changes but still supports vision', () => { + const onChange = vi.fn() + mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({ + currentModel: { + features: [ModelFeatureEnum.vision], + }, + }) + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.low, + variable_selector: ['old', 'files'], + }, + }), + onChange, + })) + + act(() => { + result.current.handleModelChanged() + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: [], + }, + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx new file mode 100644 index 0000000000..d66e3ebe4a --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx @@ -0,0 +1,146 @@ +import { renderHook } from '@testing-library/react' +import { BlockEnum } from '../../types' +import { useDynamicTestRunOptions } from '../use-dynamic-test-run-options' + +const mockUseTranslation = vi.hoisted(() => vi.fn()) +const mockUseNodes = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockUseAllTriggerPlugins = vi.hoisted(() => vi.fn()) +const mockGetWorkflowEntryNode = vi.hoisted(() => vi.fn()) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation(), +})) + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + __esModule: true, + default: () => mockUseNodes(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + buildInTools: unknown[] + customTools: unknown[] + workflowTools: unknown[] + mcpTools: unknown[] + }) => unknown) => mockUseStore(selector), +})) + +vi.mock('@/service/use-triggers', () => ({ + useAllTriggerPlugins: () => mockUseAllTriggerPlugins(), +})) + +vi.mock('@/app/components/workflow/utils/workflow-entry', () => ({ + getWorkflowEntryNode: (...args: unknown[]) => mockGetWorkflowEntryNode(...args), +})) + +describe('useDynamicTestRunOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseTranslation.mockReturnValue({ + t: (key: string) => key, + }) + mockUseStore.mockImplementation((selector: (state: { + buildInTools: unknown[] + customTools: unknown[] + workflowTools: unknown[] + mcpTools: unknown[] + }) => unknown) => selector({ + buildInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + })) + mockUseAllTriggerPlugins.mockReturnValue({ + data: [{ + name: 'plugin-provider', + icon: '/plugin-icon.png', + }], + }) + }) + + it('should build user input, trigger options, and a run-all option from workflow nodes', () => { + mockUseNodes.mockReturnValue([ + { + id: 'start-1', + data: { type: BlockEnum.Start, title: 'User Input' }, + }, + { + id: 'schedule-1', + data: { type: BlockEnum.TriggerSchedule, title: 'Daily Schedule' }, + }, + { + id: 'webhook-1', + data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' }, + }, + { + id: 'plugin-1', + data: { + type: BlockEnum.TriggerPlugin, + title: '', + plugin_name: 'Plugin Trigger', + provider_id: 'plugin-provider', + }, + }, + ]) + + const { result } = renderHook(() => useDynamicTestRunOptions()) + + expect(result.current.userInput).toEqual(expect.objectContaining({ + id: 'start-1', + type: 'user_input', + name: 'User Input', + nodeId: 'start-1', + enabled: true, + })) + expect(result.current.triggers).toEqual([ + expect.objectContaining({ + id: 'schedule-1', + type: 'schedule', + name: 'Daily Schedule', + nodeId: 'schedule-1', + }), + expect.objectContaining({ + id: 'webhook-1', + type: 'webhook', + name: 'Webhook Trigger', + nodeId: 'webhook-1', + }), + expect.objectContaining({ + id: 'plugin-1', + type: 'plugin', + name: 'Plugin Trigger', + nodeId: 'plugin-1', + }), + ]) + expect(result.current.runAll).toEqual(expect.objectContaining({ + id: 'run-all', + type: 'all', + relatedNodeIds: ['schedule-1', 'webhook-1', 'plugin-1'], + })) + }) + + it('should fall back to the workflow entry node and omit run-all when only one trigger exists', () => { + mockUseNodes.mockReturnValue([ + { + id: 'webhook-1', + data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' }, + }, + ]) + mockGetWorkflowEntryNode.mockReturnValue({ + id: 'fallback-start', + data: { type: BlockEnum.Start, title: '' }, + }) + + const { result } = renderHook(() => useDynamicTestRunOptions()) + + expect(result.current.userInput).toEqual(expect.objectContaining({ + id: 'fallback-start', + type: 'user_input', + name: 'blocks.start', + nodeId: 'fallback-start', + })) + expect(result.current.triggers).toHaveLength(1) + expect(result.current.runAll).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx new file mode 100644 index 0000000000..6dda819a04 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx @@ -0,0 +1,135 @@ +import type { TFunction } from 'i18next' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import { NodeBody, NodeDescription, NodeHeaderMeta } from '../node-sections' + +describe('node sections', () => { + it('should render loop and loading metadata in the header section', () => { + const t = ((key: string) => key) as unknown as TFunction + + render( + loop-index
} + t={t} + />, + ) + + expect(screen.getByText('loop-index')).toBeInTheDocument() + expect(document.querySelector('.i-ri-loader-2-line')).toBeInTheDocument() + }) + + it('should render the container node body and description branches', () => { + const { rerender } = render( + body-content
} + />, + ) + + expect(screen.getByText('body-content').parentElement).toHaveClass('grow') + + rerender() + expect(screen.getByText('node description')).toBeInTheDocument() + }) + + it('should render iteration parallel metadata and running progress', async () => { + const t = ((key: string) => key) as unknown as TFunction + const user = userEvent.setup() + + render( + , + ) + + expect(screen.getByText('nodes.iteration.parallelModeUpper')).toBeInTheDocument() + await user.hover(screen.getByText('nodes.iteration.parallelModeUpper')) + expect(await screen.findByText('nodes.iteration.parallelModeEnableTitle')).toBeInTheDocument() + expect(screen.getByText('nodes.iteration.parallelModeEnableDesc')).toBeInTheDocument() + expect(screen.getByText('3/3')).toBeInTheDocument() + }) + + it('should render failed, exception, success and paused status icons', () => { + const t = ((key: string) => key) as unknown as TFunction + const { rerender } = render( + , + ) + + expect(document.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-alert-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-pause-circle-fill')).toBeInTheDocument() + }) + + it('should render success icon when inspect vars exist without running status and hide description for loop nodes', () => { + const t = ((key: string) => key) as unknown as TFunction + const { rerender } = render( + , + ) + + expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument() + + rerender() + expect(screen.queryByText('hidden')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts new file mode 100644 index 0000000000..78e1f938c5 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts @@ -0,0 +1,34 @@ +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import { + getLoopIndexTextKey, + getNodeStatusBorders, + isContainerNode, + isEntryWorkflowNode, +} from '../node.helpers' + +describe('node helpers', () => { + it('should derive node border states from running status and selection state', () => { + expect(getNodeStatusBorders(NodeRunningStatus.Running, false, false).showRunningBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, false).showSuccessBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Failed, false, false).showFailedBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Exception, false, false).showExceptionBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, true).showSuccessBorder).toBe(false) + }) + + it('should expose the correct loop translation key per running status', () => { + expect(getLoopIndexTextKey(NodeRunningStatus.Running)).toBe('nodes.loop.currentLoopCount') + expect(getLoopIndexTextKey(NodeRunningStatus.Succeeded)).toBe('nodes.loop.totalLoopCount') + expect(getLoopIndexTextKey(NodeRunningStatus.Failed)).toBe('nodes.loop.totalLoopCount') + expect(getLoopIndexTextKey(NodeRunningStatus.Paused)).toBeUndefined() + }) + + it('should identify entry and container nodes', () => { + expect(isEntryWorkflowNode(BlockEnum.Start)).toBe(true) + expect(isEntryWorkflowNode(BlockEnum.TriggerWebhook)).toBe(true) + expect(isEntryWorkflowNode(BlockEnum.Tool)).toBe(false) + + expect(isContainerNode(BlockEnum.Iteration)).toBe(true) + expect(isContainerNode(BlockEnum.Loop)).toBe(true) + expect(isContainerNode(BlockEnum.Tool)).toBe(false) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx new file mode 100644 index 0000000000..a7f88e983e --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx @@ -0,0 +1,218 @@ +import type { PropsWithChildren } from 'react' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { fireEvent, screen } from '@testing-library/react' +import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import BaseNode from '../node' + +const mockHasNodeInspectVars = vi.fn() +const mockUseNodePluginInstallation = vi.fn() +const mockHandleNodeIterationChildSizeChange = vi.fn() +const mockHandleNodeLoopChildSizeChange = vi.fn() +const mockUseNodeResizeObserver = vi.fn() + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useToolIcon: () => undefined, +})) + +vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ + default: () => ({ + hasNodeInspectVars: mockHasNodeInspectVars, + }), +})) + +vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({ + useNodePluginInstallation: (...args: unknown[]) => mockUseNodePluginInstallation(...args), +})) + +vi.mock('@/app/components/workflow/nodes/iteration/use-interactions', () => ({ + useNodeIterationInteractions: () => ({ + handleNodeIterationChildSizeChange: mockHandleNodeIterationChildSizeChange, + }), +})) + +vi.mock('@/app/components/workflow/nodes/loop/use-interactions', () => ({ + useNodeLoopInteractions: () => ({ + handleNodeLoopChildSizeChange: mockHandleNodeLoopChildSizeChange, + }), +})) + +vi.mock('../use-node-resize-observer', () => ({ + default: (options: { enabled: boolean, onResize: () => void }) => { + mockUseNodeResizeObserver(options) + if (options.enabled) + options.onResize() + }, +})) + +vi.mock('../components/add-variable-popup-with-position', () => ({ + default: () =>
, +})) +vi.mock('../components/entry-node-container', () => ({ + __esModule: true, + StartNodeTypeEnum: { Start: 'start', Trigger: 'trigger' }, + default: ({ children }: PropsWithChildren) =>
{children}
, +})) +vi.mock('../components/error-handle/error-handle-on-node', () => ({ + default: () =>
, +})) +vi.mock('../components/node-control', () => ({ + default: () =>
, +})) +vi.mock('../components/node-handle', () => ({ + NodeSourceHandle: () =>
, + NodeTargetHandle: () =>
, +})) +vi.mock('../components/node-resizer', () => ({ + default: () =>
, +})) +vi.mock('../components/retry/retry-on-node', () => ({ + default: () =>
, +})) +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
, +})) +vi.mock('@/app/components/workflow/nodes/tool/components/copy-id', () => ({ + default: ({ content }: { content: string }) =>
{content}
, +})) + +const createData = (overrides: Record = {}) => ({ + type: BlockEnum.Tool, + title: 'Node title', + desc: 'Node description', + selected: false, + width: 280, + height: 180, + provider_type: 'builtin', + provider_id: 'tool-1', + _runningStatus: undefined, + _singleRunningStatus: undefined, + ...overrides, +}) + +const toNodeData = (data: ReturnType) => data as CommonNodeType + +describe('BaseNode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHasNodeInspectVars.mockReturnValue(false) + mockUseNodeResizeObserver.mockReset() + mockUseNodePluginInstallation.mockReturnValue({ + shouldDim: false, + isChecking: false, + isMissing: false, + canInstall: false, + uniqueIdentifier: undefined, + }) + }) + + it('should render content, handles and description for a regular node', () => { + renderWorkflowComponent( + +
Body
+
, + ) + + expect(screen.getByText('Node title')).toBeInTheDocument() + expect(screen.getByText('Node description')).toBeInTheDocument() + expect(screen.getByTestId('node-control')).toBeInTheDocument() + expect(screen.getByTestId('node-source-handle')).toBeInTheDocument() + expect(screen.getByTestId('node-target-handle')).toBeInTheDocument() + }) + + it('should render entry nodes inside the entry container', () => { + renderWorkflowComponent( + +
Body
+
, + ) + + expect(screen.getByTestId('entry-node-container')).toBeInTheDocument() + }) + + it('should block interaction when plugin installation is required', () => { + mockUseNodePluginInstallation.mockReturnValue({ + shouldDim: false, + isChecking: false, + isMissing: true, + canInstall: true, + uniqueIdentifier: 'plugin-1', + }) + + renderWorkflowComponent( + +
Body
+
, + ) + + const overlay = screen.getByTestId('workflow-node-install-overlay') + expect(overlay).toBeInTheDocument() + fireEvent.click(overlay) + }) + + it('should render running status indicators for loop nodes', () => { + renderWorkflowComponent( + +
Loop body
+
, + ) + + expect(screen.getByText(/workflow\.nodes\.loop\.currentLoopCount/)).toBeInTheDocument() + expect(screen.getByTestId('node-resizer')).toBeInTheDocument() + }) + + it('should render an iteration node resizer and dimmed overlay', () => { + mockUseNodePluginInstallation.mockReturnValue({ + shouldDim: true, + isChecking: false, + isMissing: false, + canInstall: false, + uniqueIdentifier: undefined, + }) + + renderWorkflowComponent( + +
Iteration body
+
, + ) + + expect(screen.getByTestId('node-resizer')).toBeInTheDocument() + expect(screen.getByTestId('workflow-node-install-overlay')).toBeInTheDocument() + expect(mockHandleNodeIterationChildSizeChange).toHaveBeenCalledWith('node-1') + }) + + it('should trigger loop resize updates when the selected node is inside a loop', () => { + renderWorkflowComponent( + +
Loop body
+
, + ) + + expect(mockHandleNodeLoopChildSizeChange).toHaveBeenCalledWith('node-2') + expect(mockUseNodeResizeObserver).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx new file mode 100644 index 0000000000..9ee377be4d --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx @@ -0,0 +1,55 @@ +import { renderHook } from '@testing-library/react' +import useNodeResizeObserver from '../use-node-resize-observer' + +describe('useNodeResizeObserver', () => { + it('should observe and disconnect when enabled with a mounted node ref', () => { + const observe = vi.fn() + const disconnect = vi.fn() + const onResize = vi.fn() + let resizeCallback: (() => void) | undefined + + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { + resizeCallback = callback + } + + observe = observe + disconnect = disconnect + unobserve = vi.fn() + }) + + const node = document.createElement('div') + const nodeRef = { current: node } + + const { unmount } = renderHook(() => useNodeResizeObserver({ + enabled: true, + nodeRef, + onResize, + })) + + expect(observe).toHaveBeenCalledWith(node) + resizeCallback?.() + expect(onResize).toHaveBeenCalledTimes(1) + + unmount() + expect(disconnect).toHaveBeenCalledTimes(1) + }) + + it('should do nothing when disabled', () => { + const observe = vi.fn() + + vi.stubGlobal('ResizeObserver', class { + observe = observe + disconnect = vi.fn() + unobserve = vi.fn() + }) + + renderHook(() => useNodeResizeObserver({ + enabled: false, + nodeRef: { current: document.createElement('div') }, + onResize: vi.fn(), + })) + + expect(observe).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx new file mode 100644 index 0000000000..49de788314 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -0,0 +1,410 @@ +import type { ComponentProps } from 'react' +import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { VarKindType } from '../../types' +import FormInputItem from '../form-input-item' + +const { + mockFetchDynamicOptions, + mockTriggerDynamicOptionsState, +} = vi.hoisted(() => ({ + mockFetchDynamicOptions: vi.fn(), + mockTriggerDynamicOptionsState: { + data: undefined as { options: FormOption[] } | undefined, + isLoading: false, + }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/service/use-plugins', () => ({ + useFetchDynamicOptions: () => ({ + mutateAsync: mockFetchDynamicOptions, + }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useTriggerPluginDynamicOptions: () => mockTriggerDynamicOptionsState, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ + default: ({ onSelect }: { onSelect: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ + default: ({ setModel }: { setModel: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({ + default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( + onChange(e.target.value)} /> + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( +