-
{label[language] || label.en_US}
+
{fieldTitle}
{required && (
*
)}
{tooltipContent}
·
-
{targetVarType()}
+
{resolveTargetVarType(type)}
{isShowJSONEditor && (
= ({
>
showSchema(input_schema as SchemaRoot, label[language] || label.en_US)}
+ onClick={() => showSchema(input_schema as SchemaRoot, fieldTitle)}
>
@@ -295,12 +228,7 @@ const ReasoningConfigForm: React.FC = ({
{
- if (option.show_on.length)
- return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value)
-
- return true
- }).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
+ items={pickerProps.selectItems}
onSelect={item => handleValueChange(variable, type)(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
@@ -347,9 +275,9 @@ const ReasoningConfigForm: React.FC = ({
nodeId={nodeId}
value={(varInput?.value as string | ValueSelector) || []}
onChange={handleVariableSelectorChange(variable)}
- filterVar={getFilterVar()}
- schema={schema as Partial}
- valueTypePlaceHolder={targetVarType()}
+ filterVar={pickerProps.filterVar}
+ schema={pickerProps.schema}
+ valueTypePlaceHolder={pickerProps.targetVarType}
/>
)}
diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts
new file mode 100644
index 0000000000..33a05be1b8
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest'
+import { usePluginInstalledCheck, useToolSelectorState } from '../index'
+
+describe('tool-selector hooks index', () => {
+ it('re-exports the tool selector hooks', () => {
+ expect(usePluginInstalledCheck).toBeTypeOf('function')
+ expect(useToolSelectorState).toBeTypeOf('function')
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx
new file mode 100644
index 0000000000..476ab8e145
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx
@@ -0,0 +1,76 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useGlobalPublicStore } from '@/context/global-public-context'
+import { renderWithNuqs } from '@/test/nuqs-testing'
+import { usePluginPageContext } from '../context'
+import { PluginPageContextProvider } from '../context-provider'
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: vi.fn(),
+}))
+
+vi.mock('../../hooks', () => ({
+ PLUGIN_PAGE_TABS_MAP: {
+ plugins: 'plugins',
+ marketplace: 'discover',
+ },
+ usePluginPageTabs: () => [
+ { value: 'plugins', text: 'Plugins' },
+ { value: 'discover', text: 'Discover' },
+ ],
+}))
+
+const mockGlobalPublicStore = (enableMarketplace: boolean) => {
+ vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
+ const state = { systemFeatures: { enable_marketplace: enableMarketplace } }
+ return selector(state as Parameters
[0])
+ })
+}
+
+const Consumer = () => {
+ const currentPluginID = usePluginPageContext(v => v.currentPluginID)
+ const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID)
+ const options = usePluginPageContext(v => v.options)
+
+ return (
+
+ {currentPluginID ?? 'none'}
+ {options.length}
+
+
+ )
+}
+
+describe('PluginPageContextProvider', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('filters out the marketplace tab when the feature is disabled', () => {
+ mockGlobalPublicStore(false)
+
+ renderWithNuqs(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('options-count')).toHaveTextContent('1')
+ })
+
+ it('keeps the query-state tab and updates the current plugin id', () => {
+ mockGlobalPublicStore(true)
+
+ renderWithNuqs(
+
+
+ ,
+ { searchParams: '?tab=discover' },
+ )
+
+ fireEvent.click(screen.getByText('select plugin'))
+
+ expect(screen.getByTestId('current-plugin')).toHaveTextContent('plugin-1')
+ expect(screen.getByTestId('options-count')).toHaveTextContent('2')
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx
new file mode 100644
index 0000000000..ceec84a286
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx
@@ -0,0 +1,89 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import DebugInfo from '../debug-info'
+
+const mockDebugKey = vi.hoisted(() => ({
+ data: null as null | { key: string, host: string, port: number },
+ isLoading: false,
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.example.com${path}`,
+}))
+
+vi.mock('@/service/use-plugins', () => ({
+ useDebugKey: () => mockDebugKey,
+}))
+
+vi.mock('@/app/components/base/button', () => ({
+ default: ({ children }: { children: React.ReactNode }) => ,
+}))
+
+vi.mock('@/app/components/base/tooltip', () => ({
+ default: ({
+ children,
+ disabled,
+ popupContent,
+ }: {
+ children: React.ReactNode
+ disabled?: boolean
+ popupContent: React.ReactNode
+ }) => (
+
+ {children}
+ {!disabled &&
{popupContent}
}
+
+ ),
+}))
+
+vi.mock('../../base/key-value-item', () => ({
+ default: ({
+ label,
+ value,
+ maskedValue,
+ }: {
+ label: string
+ value: string
+ maskedValue?: string
+ }) => (
+
+ {label}
+ :
+ {maskedValue || value}
+
+ ),
+}))
+
+describe('DebugInfo', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDebugKey.data = null
+ mockDebugKey.isLoading = false
+ })
+
+ it('renders nothing while the debug key is loading', () => {
+ mockDebugKey.isLoading = true
+ const { container } = render()
+
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('renders debug metadata and masks the key when info is available', () => {
+ mockDebugKey.data = {
+ host: '127.0.0.1',
+ port: 5001,
+ key: '12345678abcdefghijklmnopqrst87654321',
+ }
+
+ render()
+
+ expect(screen.getByTestId('debug-button')).toBeInTheDocument()
+ expect(screen.getByText('plugin.debugInfo.title')).toBeInTheDocument()
+ expect(screen.getByRole('link')).toHaveAttribute(
+ 'href',
+ 'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin',
+ )
+ expect(screen.getByTestId('kv-URL')).toHaveTextContent('URL:127.0.0.1:5001')
+ expect(screen.getByTestId('kv-Key')).toHaveTextContent('Key:12345678********87654321')
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx
new file mode 100644
index 0000000000..d3b72ebe5b
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx
@@ -0,0 +1,156 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import InstallPluginDropdown from '../install-plugin-dropdown'
+
+let portalOpen = false
+const {
+ mockSystemFeatures,
+} = vi.hoisted(() => ({
+ mockSystemFeatures: {
+ enable_marketplace: true,
+ plugin_installation_permission: {
+ restrict_to_marketplace_only: false,
+ },
+ },
+}))
+
+vi.mock('@/config', () => ({
+ SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS: '.difypkg,.zip',
+}))
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
+ selector({ systemFeatures: mockSystemFeatures }),
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/solid/files', () => ({
+ FileZip: () => file,
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
+ Github: () => github,
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({
+ MagicBox: () => magic,
+}))
+
+vi.mock('@/app/components/base/button', () => ({
+ default: ({ children }: { children: React.ReactNode }) => {children},
+}))
+
+vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
+ const React = await import('react')
+ return {
+ PortalToFollowElem: ({
+ open,
+ children,
+ }: {
+ open: boolean
+ children: React.ReactNode
+ }) => {
+ portalOpen = open
+ return {children}
+ },
+ PortalToFollowElemTrigger: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode
+ onClick: () => void
+ }) => ,
+ PortalToFollowElemContent: ({
+ children,
+ }: {
+ children: React.ReactNode
+ }) => portalOpen ? {children}
: null,
+ }
+})
+
+vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({
+ default: ({ onClose }: { onClose: () => void }) => (
+
+
+
+ ),
+}))
+
+vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({
+ default: ({
+ file,
+ onClose,
+ }: {
+ file: File
+ onClose: () => void
+ }) => (
+
+ {file.name}
+
+
+ ),
+}))
+
+describe('InstallPluginDropdown', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ portalOpen = false
+ mockSystemFeatures.enable_marketplace = true
+ mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = false
+ })
+
+ it('shows all install methods when marketplace and custom installs are enabled', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('dropdown-trigger'))
+
+ expect(screen.getByText('plugin.installFrom')).toBeInTheDocument()
+ expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument()
+ expect(screen.getByText('plugin.source.github')).toBeInTheDocument()
+ expect(screen.getByText('plugin.source.local')).toBeInTheDocument()
+ })
+
+ it('shows only marketplace when installation is restricted', () => {
+ mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = true
+
+ render()
+
+ fireEvent.click(screen.getByTestId('dropdown-trigger'))
+
+ expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument()
+ expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument()
+ expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument()
+ })
+
+ it('switches to marketplace when the marketplace action is selected', () => {
+ const onSwitchToMarketplaceTab = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('dropdown-trigger'))
+ fireEvent.click(screen.getByText('plugin.source.marketplace'))
+
+ expect(onSwitchToMarketplaceTab).toHaveBeenCalledTimes(1)
+ })
+
+ it('opens the github installer when github is selected', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('dropdown-trigger'))
+ fireEvent.click(screen.getByText('plugin.source.github'))
+
+ expect(screen.getByTestId('github-modal')).toBeInTheDocument()
+ })
+
+ it('opens the local package installer when a file is selected', () => {
+ const { container } = render()
+
+ fireEvent.click(screen.getByTestId('dropdown-trigger'))
+ fireEvent.change(container.querySelector('input[type="file"]')!, {
+ target: {
+ files: [new File(['content'], 'plugin.difypkg')],
+ },
+ })
+
+ expect(screen.getByTestId('local-modal')).toBeInTheDocument()
+ expect(screen.getByText('plugin.difypkg')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx
new file mode 100644
index 0000000000..bad857077a
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx
@@ -0,0 +1,200 @@
+import type { PluginDetail } from '../../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import PluginsPanel from '../plugins-panel'
+
+const mockState = vi.hoisted(() => ({
+ filters: {
+ categories: [] as string[],
+ tags: [] as string[],
+ searchQuery: '',
+ },
+ currentPluginID: undefined as string | undefined,
+}))
+
+const mockSetFilters = vi.fn()
+const mockSetCurrentPluginID = vi.fn()
+const mockLoadNextPage = vi.fn()
+const mockInvalidateInstalledPluginList = vi.fn()
+const mockUseInstalledPluginList = vi.fn()
+const mockPluginListWithLatestVersion = vi.fn<() => PluginDetail[]>(() => [])
+
+vi.mock('@/context/i18n', () => ({
+ useGetLanguage: () => 'en_US',
+}))
+
+vi.mock('@/i18n-config', () => ({
+ renderI18nObject: (value: Record, locale: string) => value[locale] || '',
+}))
+
+vi.mock('@/service/use-plugins', () => ({
+ useInstalledPluginList: () => mockUseInstalledPluginList(),
+ useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
+}))
+
+vi.mock('../../hooks', () => ({
+ usePluginsWithLatestVersion: () => mockPluginListWithLatestVersion(),
+}))
+
+vi.mock('../context', () => ({
+ usePluginPageContext: (selector: (value: {
+ filters: typeof mockState.filters
+ setFilters: typeof mockSetFilters
+ currentPluginID: string | undefined
+ setCurrentPluginID: typeof mockSetCurrentPluginID
+ }) => unknown) => selector({
+ filters: mockState.filters,
+ setFilters: mockSetFilters,
+ currentPluginID: mockState.currentPluginID,
+ setCurrentPluginID: mockSetCurrentPluginID,
+ }),
+}))
+
+vi.mock('../filter-management', () => ({
+ default: ({ onFilterChange }: { onFilterChange: (filters: typeof mockState.filters) => void }) => (
+
+ ),
+}))
+
+vi.mock('../empty', () => ({
+ default: () => empty
,
+}))
+
+vi.mock('../list', () => ({
+ default: ({ pluginList }: { pluginList: PluginDetail[] }) => {pluginList.map(plugin => plugin.plugin_id).join(',')}
,
+}))
+
+vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
+ default: ({ detail, onHide, onUpdate }: {
+ detail?: PluginDetail
+ onHide: () => void
+ onUpdate: () => void
+ }) => (
+
+ {detail?.plugin_id ?? 'none'}
+
+
+
+ ),
+}))
+
+const createPlugin = (pluginId: string, label: string, tags: string[] = []): PluginDetail => ({
+ id: pluginId,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ name: label,
+ plugin_id: pluginId,
+ plugin_unique_identifier: `${pluginId}-uid`,
+ declaration: {
+ category: 'tool',
+ name: pluginId,
+ label: { en_US: label },
+ description: { en_US: `${label} description` },
+ tags,
+ } as PluginDetail['declaration'],
+ installation_id: `${pluginId}-install`,
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: `${pluginId}-uid`,
+ source: 'marketplace' as PluginDetail['source'],
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+}) as PluginDetail
+
+describe('PluginsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ mockState.filters = { categories: [], tags: [], searchQuery: '' }
+ mockState.currentPluginID = undefined
+ mockUseInstalledPluginList.mockReturnValue({
+ data: { plugins: [] },
+ isLoading: false,
+ isFetching: false,
+ isLastPage: true,
+ loadNextPage: mockLoadNextPage,
+ })
+ mockPluginListWithLatestVersion.mockReturnValue([])
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('renders the loading state while the plugin list is pending', () => {
+ mockUseInstalledPluginList.mockReturnValue({
+ data: { plugins: [] },
+ isLoading: true,
+ isFetching: false,
+ isLastPage: true,
+ loadNextPage: mockLoadNextPage,
+ })
+
+ render()
+
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('filters the list and exposes the load-more action', () => {
+ mockState.filters.searchQuery = 'alpha'
+ mockPluginListWithLatestVersion.mockReturnValue([
+ createPlugin('alpha-tool', 'Alpha Tool', ['search']),
+ createPlugin('beta-tool', 'Beta Tool', ['rag']),
+ ])
+ mockUseInstalledPluginList.mockReturnValue({
+ data: { plugins: [] },
+ isLoading: false,
+ isFetching: false,
+ isLastPage: false,
+ loadNextPage: mockLoadNextPage,
+ })
+
+ render()
+
+ expect(screen.getByTestId('plugin-list')).toHaveTextContent('alpha-tool')
+ expect(screen.queryByText('beta-tool')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('workflow.common.loadMore'))
+ fireEvent.click(screen.getByTestId('filter-management'))
+ vi.runAllTimers()
+
+ expect(mockLoadNextPage).toHaveBeenCalled()
+ expect(mockSetFilters).toHaveBeenCalledWith({
+ categories: [],
+ tags: [],
+ searchQuery: 'beta',
+ })
+ })
+
+ it('renders the empty state and keeps the current plugin detail in sync', () => {
+ mockState.currentPluginID = 'beta-tool'
+ mockState.filters.searchQuery = 'missing'
+ mockPluginListWithLatestVersion.mockReturnValue([
+ createPlugin('beta-tool', 'Beta Tool'),
+ ])
+
+ render()
+
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument()
+ expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('beta-tool')
+
+ fireEvent.click(screen.getByText('hide detail'))
+ fireEvent.click(screen.getByText('refresh detail'))
+
+ expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined)
+ expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts b/web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts
new file mode 100644
index 0000000000..7286ff549f
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts
@@ -0,0 +1,32 @@
+import type { Category, Tag } from '../constant'
+import { describe, expect, it } from 'vitest'
+
+describe('filter-management constant types', () => {
+ it('accepts tag objects with binding counts', () => {
+ const tag: Tag = {
+ id: 'tag-1',
+ name: 'search',
+ type: 'plugin',
+ binding_count: 3,
+ }
+
+ expect(tag).toEqual({
+ id: 'tag-1',
+ name: 'search',
+ type: 'plugin',
+ binding_count: 3,
+ })
+ })
+
+ it('accepts supported category names', () => {
+ const category: Category = {
+ name: 'tool',
+ binding_count: 8,
+ }
+
+ expect(category).toEqual({
+ name: 'tool',
+ binding_count: 8,
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx
new file mode 100644
index 0000000000..ff3cd3d97c
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx
@@ -0,0 +1,76 @@
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import TagFilter from '../tag-filter'
+
+let portalOpen = false
+
+vi.mock('../../../hooks', () => ({
+ useTags: () => ({
+ tags: [
+ { name: 'agent', label: 'Agent' },
+ { name: 'rag', label: 'RAG' },
+ { name: 'search', label: 'Search' },
+ ],
+ getTagLabel: (name: string) => ({
+ agent: 'Agent',
+ rag: 'RAG',
+ search: 'Search',
+ }[name] ?? name),
+ }),
+}))
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
+ PortalToFollowElem: ({
+ children,
+ open,
+ }: {
+ children: React.ReactNode
+ open: boolean
+ }) => {
+ portalOpen = open
+ return {children}
+ },
+ PortalToFollowElemTrigger: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode
+ onClick: () => void
+ }) => ,
+ PortalToFollowElemContent: ({
+ children,
+ }: {
+ children: React.ReactNode
+ }) => portalOpen ? {children}
: null,
+}))
+
+describe('TagFilter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ portalOpen = false
+ })
+
+ it('renders selected tag labels and the overflow counter', () => {
+ render()
+
+ expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
+ expect(screen.getByText('+1')).toBeInTheDocument()
+ })
+
+ it('filters options by search text and toggles tag selection', () => {
+ const onChange = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('trigger'))
+ const portal = screen.getByTestId('portal-content')
+
+ fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } })
+
+ expect(within(portal).queryByText('Agent')).not.toBeInTheDocument()
+ expect(within(portal).getByText('RAG')).toBeInTheDocument()
+
+ fireEvent.click(within(portal).getByText('RAG'))
+
+ expect(onChange).toHaveBeenCalledWith(['agent', 'rag'])
+ })
+})
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts
new file mode 100644
index 0000000000..36450a4386
--- /dev/null
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest'
+import { defaultValue } from '../config'
+import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../types'
+
+describe('auto-update config', () => {
+ it('provides the expected default auto update value', () => {
+ expect(defaultValue).toEqual({
+ strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
+ upgrade_time_of_day: 0,
+ upgrade_mode: AUTO_UPDATE_MODE.update_all,
+ exclude_plugins: [],
+ include_plugins: [],
+ })
+ })
+})
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx
new file mode 100644
index 0000000000..d205682690
--- /dev/null
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import NoDataPlaceholder from '../no-data-placeholder'
+
+describe('NoDataPlaceholder', () => {
+ it('renders the no-found state by default', () => {
+ const { container } = render()
+
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noFound')).toBeInTheDocument()
+ })
+
+ it('renders the no-installed state when noPlugins is true', () => {
+ const { container } = render()
+
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx
new file mode 100644
index 0000000000..ba172ad3d6
--- /dev/null
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import NoPluginSelected from '../no-plugin-selected'
+import { AUTO_UPDATE_MODE } from '../types'
+
+describe('NoPluginSelected', () => {
+ it('renders partial mode placeholder', () => {
+ render()
+
+ expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument()
+ })
+
+ it('renders exclude mode placeholder', () => {
+ render()
+
+ expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.exclude')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx
new file mode 100644
index 0000000000..4330f35bb4
--- /dev/null
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx
@@ -0,0 +1,82 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import PluginsPicker from '../plugins-picker'
+import { AUTO_UPDATE_MODE } from '../types'
+
+const mockToolPicker = vi.fn()
+
+vi.mock('@/app/components/base/button', () => ({
+ default: ({
+ children,
+ }: {
+ children: React.ReactNode
+ }) => ,
+}))
+
+vi.mock('../no-plugin-selected', () => ({
+ default: ({ updateMode }: { updateMode: AUTO_UPDATE_MODE }) => {updateMode}
,
+}))
+
+vi.mock('../plugins-selected', () => ({
+ default: ({ plugins }: { plugins: string[] }) => {plugins.join(',')}
,
+}))
+
+vi.mock('../tool-picker', () => ({
+ default: (props: Record) => {
+ mockToolPicker(props)
+ return tool-picker
+ },
+}))
+
+describe('PluginsPicker', () => {
+ it('renders the empty state when no plugins are selected', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('no-plugin-selected')).toHaveTextContent(AUTO_UPDATE_MODE.partial)
+ expect(screen.queryByTestId('plugins-selected')).not.toBeInTheDocument()
+ expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({
+ value: [],
+ isShow: false,
+ onShowChange: expect.any(Function),
+ }))
+ })
+
+ it('renders selected plugins summary and clears them', () => {
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":2}')).toBeInTheDocument()
+ expect(screen.getByTestId('plugins-selected')).toHaveTextContent('dify/plugin-1,dify/plugin-2')
+
+ fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll'))
+
+ expect(onChange).toHaveBeenCalledWith([])
+ })
+
+ it('passes the select button trigger into ToolPicker', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('tool-picker')).toBeInTheDocument()
+ expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({
+ trigger: expect.anything(),
+ }))
+ })
+})
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx
new file mode 100644
index 0000000000..cc4693f89c
--- /dev/null
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import PluginsSelected from '../plugins-selected'
+
+vi.mock('@/config', () => ({
+ MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
+}))
+
+vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
+ default: ({ src }: { src: string }) => {src}
,
+}))
+
+describe('PluginsSelected', () => {
+ it('renders all selected plugin icons when the count is below the limit', () => {
+ render()
+
+ expect(screen.getAllByTestId('plugin-icon')).toHaveLength(2)
+ expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument()
+ expect(screen.queryByText('+1')).not.toBeInTheDocument()
+ })
+
+ it('renders the overflow badge when more than fourteen plugins are selected', () => {
+ const plugins = Array.from({ length: 16 }, (_, index) => `dify/plugin-${index}`)
+ render()
+
+ expect(screen.getAllByTestId('plugin-icon')).toHaveLength(14)
+ expect(screen.getByText('+2')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx
new file mode 100644
index 0000000000..aec57a2739
--- /dev/null
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx
@@ -0,0 +1,100 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import StrategyPicker from '../strategy-picker'
+import { AUTO_UPDATE_STRATEGY } from '../types'
+
+let portalOpen = false
+
+vi.mock('@/app/components/base/button', () => ({
+ default: ({
+ children,
+ }: {
+ children: React.ReactNode
+ }) => {children},
+}))
+
+vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
+ const React = await import('react')
+ return {
+ PortalToFollowElem: ({
+ open,
+ children,
+ }: {
+ open: boolean
+ children: React.ReactNode
+ }) => {
+ portalOpen = open
+ return {children}
+ },
+ PortalToFollowElemTrigger: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode
+ onClick: (event: { stopPropagation: () => void, nativeEvent: { stopImmediatePropagation: () => void } }) => void
+ }) => (
+
+ ),
+ PortalToFollowElemContent: ({
+ children,
+ }: {
+ children: React.ReactNode
+ }) => portalOpen ? {children}
: null,
+ }
+})
+
+describe('StrategyPicker', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ portalOpen = false
+ })
+
+ it('renders the selected strategy label in the trigger', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('trigger')).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
+ })
+
+ it('opens the option list when the trigger is clicked', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('trigger'))
+
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+ expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1)
+ expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument()
+ })
+
+ it('calls onChange when a new strategy is selected', () => {
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('trigger'))
+ fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name'))
+
+ expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
+ })
+})
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx
new file mode 100644
index 0000000000..f15fe5933f
--- /dev/null
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx
@@ -0,0 +1,65 @@
+import type { PluginDetail } from '@/app/components/plugins/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import ToolItem from '../tool-item'
+
+vi.mock('@/config', () => ({
+ MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useGetLanguage: () => 'en_US',
+}))
+
+vi.mock('@/i18n-config', () => ({
+ renderI18nObject: (value: Record, language: string) => value[language],
+}))
+
+vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
+ default: ({ src }: { src: string }) => {src}
,
+}))
+
+vi.mock('@/app/components/base/checkbox', () => ({
+ default: ({
+ checked,
+ onCheck,
+ }: {
+ checked?: boolean
+ onCheck: () => void
+ }) => (
+
+ ),
+}))
+
+const payload = {
+ plugin_id: 'dify/plugin-1',
+ declaration: {
+ label: {
+ en_US: 'Plugin One',
+ zh_Hans: 'Plugin One',
+ },
+ author: 'Dify',
+ },
+} as PluginDetail
+
+describe('ToolItem', () => {
+ it('renders plugin metadata and marketplace icon', () => {
+ render()
+
+ expect(screen.getByText('Plugin One')).toBeInTheDocument()
+ expect(screen.getByText('Dify')).toBeInTheDocument()
+ expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument()
+ expect(screen.getByText('true')).toBeInTheDocument()
+ })
+
+ it('calls onCheckChange when checkbox is clicked', () => {
+ const onCheckChange = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('checkbox'))
+
+ expect(onCheckChange).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx
new file mode 100644
index 0000000000..9e63622d3f
--- /dev/null
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx
@@ -0,0 +1,248 @@
+import type { PluginDetail } from '@/app/components/plugins/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PluginSource } from '@/app/components/plugins/types'
+import ToolPicker from '../tool-picker'
+
+let portalOpen = false
+
+const mockInstalledPluginList = vi.hoisted(() => ({
+ data: {
+ plugins: [] as PluginDetail[],
+ },
+ isLoading: false,
+}))
+
+vi.mock('@/service/use-plugins', () => ({
+ useInstalledPluginList: () => mockInstalledPluginList,
+}))
+
+vi.mock('@/app/components/base/loading', () => ({
+ default: () => loading
,
+}))
+
+vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
+ const React = await import('react')
+ return {
+ PortalToFollowElem: ({
+ open,
+ children,
+ }: {
+ open: boolean
+ children: React.ReactNode
+ }) => {
+ portalOpen = open
+ return {children}
+ },
+ PortalToFollowElemTrigger: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode
+ onClick: () => void
+ }) => ,
+ PortalToFollowElemContent: ({
+ children,
+ className,
+ }: {
+ children: React.ReactNode
+ className?: string
+ }) => portalOpen ? {children}
: null,
+ }
+})
+
+vi.mock('@/app/components/plugins/marketplace/search-box', () => ({
+ default: ({
+ search,
+ tags,
+ onSearchChange,
+ onTagsChange,
+ placeholder,
+ }: {
+ search: string
+ tags: string[]
+ onSearchChange: (value: string) => void
+ onTagsChange: (value: string[]) => void
+ placeholder: string
+ }) => (
+
+
{placeholder}
+
{search}
+
{tags.join(',')}
+
+
+
+ ),
+}))
+
+vi.mock('../no-data-placeholder', () => ({
+ default: ({
+ noPlugins,
+ }: {
+ noPlugins?: boolean
+ }) => {String(noPlugins)}
,
+}))
+
+vi.mock('../tool-item', () => ({
+ default: ({
+ payload,
+ isChecked,
+ onCheckChange,
+ }: {
+ payload: PluginDetail
+ isChecked?: boolean
+ onCheckChange: () => void
+ }) => (
+
+ {payload.plugin_id}
+ {String(isChecked)}
+
+
+ ),
+}))
+
+const createPlugin = (
+ pluginId: string,
+ source: PluginDetail['source'],
+ category: string,
+ tags: string[],
+): PluginDetail => ({
+ plugin_id: pluginId,
+ source,
+ declaration: {
+ category,
+ tags,
+ },
+} as PluginDetail)
+
+describe('ToolPicker', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ portalOpen = false
+ mockInstalledPluginList.data = {
+ plugins: [],
+ }
+ mockInstalledPluginList.isLoading = false
+ })
+
+ it('toggles popup visibility from the trigger', () => {
+ const onShowChange = vi.fn()
+ render(
+ trigger}
+ value={[]}
+ onChange={vi.fn()}
+ isShow={false}
+ onShowChange={onShowChange}
+ />,
+ )
+
+ fireEvent.click(screen.getByTestId('trigger'))
+
+ expect(onShowChange).toHaveBeenCalledWith(true)
+ })
+
+ it('renders loading content while installed plugins are loading', () => {
+ mockInstalledPluginList.isLoading = true
+
+ render(
+ trigger}
+ value={[]}
+ onChange={vi.fn()}
+ isShow
+ onShowChange={vi.fn()}
+ />,
+ )
+
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ it('renders no-data placeholder when there are no matching marketplace plugins', () => {
+ render(
+ trigger}
+ value={[]}
+ onChange={vi.fn()}
+ isShow
+ onShowChange={vi.fn()}
+ />,
+ )
+
+ expect(screen.getByTestId('no-data')).toHaveTextContent('true')
+ })
+
+ it('filters by plugin type, tags, and query', () => {
+ mockInstalledPluginList.data = {
+ plugins: [
+ createPlugin('tool-search', PluginSource.marketplace, 'tool', ['search']),
+ createPlugin('tool-rag', PluginSource.marketplace, 'tool', ['rag']),
+ createPlugin('model-agent', PluginSource.marketplace, 'model', ['agent']),
+ createPlugin('github-tool', PluginSource.github, 'tool', ['rag']),
+ ],
+ }
+
+ render(
+ trigger}
+ value={[]}
+ onChange={vi.fn()}
+ isShow
+ onShowChange={vi.fn()}
+ />,
+ )
+
+ expect(screen.getAllByTestId('tool-item')).toHaveLength(3)
+ expect(screen.queryByText('github-tool')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('plugin.category.models'))
+ expect(screen.getAllByTestId('tool-item')).toHaveLength(1)
+ expect(screen.getByText('model-agent')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('plugin.category.tools'))
+ expect(screen.getAllByTestId('tool-item')).toHaveLength(2)
+
+ fireEvent.click(screen.getByTestId('set-tags'))
+ expect(screen.getAllByTestId('tool-item')).toHaveLength(1)
+ expect(screen.getByText('tool-rag')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('set-query'))
+ expect(screen.getAllByTestId('tool-item')).toHaveLength(1)
+ expect(screen.getByTestId('search-state')).toHaveTextContent('tool-rag')
+ })
+
+ it('adds and removes plugin ids from the selection', () => {
+ mockInstalledPluginList.data = {
+ plugins: [
+ createPlugin('tool-rag', PluginSource.marketplace, 'tool', ['rag']),
+ createPlugin('tool-search', PluginSource.marketplace, 'tool', ['search']),
+ ],
+ }
+ const onChange = vi.fn()
+ const { rerender } = render(
+ trigger}
+ value={['tool-rag']}
+ onChange={onChange}
+ isShow
+ onShowChange={vi.fn()}
+ />,
+ )
+
+ fireEvent.click(screen.getByTestId('toggle-tool-search'))
+ expect(onChange).toHaveBeenCalledWith(['tool-rag', 'tool-search'])
+
+ rerender(
+ trigger}
+ value={['tool-rag']}
+ onChange={onChange}
+ isShow
+ onShowChange={vi.fn()}
+ />,
+ )
+
+ fireEvent.click(screen.getByTestId('toggle-tool-rag'))
+ expect(onChange).toHaveBeenCalledWith([])
+ })
+})
diff --git a/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx
new file mode 100644
index 0000000000..b66ab20a45
--- /dev/null
+++ b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx
@@ -0,0 +1,226 @@
+import type { UpdateFromMarketPlacePayload } from '@/app/components/plugins/types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
+import UpdateFromMarketplace from '../from-market-place'
+
+const {
+ mockStop,
+ mockCheck,
+ mockHandleRefetch,
+ mockInvalidateReferenceSettings,
+ mockRemoveAutoUpgrade,
+ mockUpdateFromMarketPlace,
+ mockToastError,
+} = vi.hoisted(() => ({
+ mockStop: vi.fn(),
+ mockCheck: vi.fn(),
+ mockHandleRefetch: vi.fn(),
+ mockInvalidateReferenceSettings: vi.fn(),
+ mockRemoveAutoUpgrade: vi.fn(),
+ mockUpdateFromMarketPlace: vi.fn(),
+ mockToastError: vi.fn(),
+}))
+
+vi.mock('@/app/components/base/ui/dialog', () => ({
+ Dialog: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogTitle: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogCloseButton: () => ,
+}))
+
+vi.mock('@/app/components/base/badge/index', () => ({
+ __esModule: true,
+ BadgeState: {
+ Warning: 'warning',
+ },
+ default: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+
+vi.mock('@/app/components/base/button', () => ({
+ default: ({
+ children,
+ onClick,
+ disabled,
+ }: {
+ children: React.ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ }) => ,
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ error: mockToastError,
+ },
+}))
+
+vi.mock('@/app/components/plugins/card', () => ({
+ default: ({ titleLeft, payload }: { titleLeft: React.ReactNode, payload: { label: Record } }) => (
+
+
{payload.label.en_US}
+
{titleLeft}
+
+ ),
+}))
+
+vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
+ default: () => ({
+ check: mockCheck,
+ stop: mockStop,
+ }),
+}))
+
+vi.mock('@/app/components/plugins/install-plugin/utils', () => ({
+ pluginManifestToCardPluginProps: (payload: unknown) => payload,
+}))
+
+vi.mock('@/service/plugins', () => ({
+ updateFromMarketPlace: mockUpdateFromMarketPlace,
+}))
+
+vi.mock('@/service/use-plugins', () => ({
+ usePluginTaskList: () => ({
+ handleRefetch: mockHandleRefetch,
+ }),
+ useRemoveAutoUpgrade: () => ({
+ mutateAsync: mockRemoveAutoUpgrade,
+ }),
+ useInvalidateReferenceSettings: () => mockInvalidateReferenceSettings,
+}))
+
+vi.mock('../install-plugin/base/use-get-icon', () => ({
+ default: () => ({
+ getIconUrl: async (icon: string) => `https://cdn.example.com/${icon}`,
+ }),
+}))
+
+vi.mock('../downgrade-warning', () => ({
+ default: ({
+ onCancel,
+ onJustDowngrade,
+ onExcludeAndDowngrade,
+ }: {
+ onCancel: () => void
+ onJustDowngrade: () => void
+ onExcludeAndDowngrade: () => void
+ }) => (
+
+
+
+
+
+ ),
+}))
+
+const createPayload = (overrides: Partial = {}): UpdateFromMarketPlacePayload => ({
+ category: PluginCategoryEnum.tool,
+ originalPackageInfo: {
+ id: 'plugin@1.0.0',
+ payload: {
+ version: '1.0.0',
+ icon: 'plugin.png',
+ label: { en_US: 'Plugin Label' },
+ } as UpdateFromMarketPlacePayload['originalPackageInfo']['payload'],
+ },
+ targetPackageInfo: {
+ id: 'plugin@2.0.0',
+ version: '2.0.0',
+ },
+ ...overrides,
+})
+
+describe('UpdateFromMarketplace', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheck.mockResolvedValue({ status: TaskStatus.success })
+ mockUpdateFromMarketPlace.mockResolvedValue({
+ all_installed: true,
+ task_id: 'task-1',
+ })
+ })
+
+ it('renders the upgrade modal content and current version transition', async () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument()
+ expect(screen.getByText('plugin.upgrade.description')).toBeInTheDocument()
+ expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByTestId('plugin-card')).toHaveTextContent('Plugin Label')
+ })
+ })
+
+ it('submits the marketplace upgrade and calls onSave when installation is immediate', async () => {
+ const onSave = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('plugin.upgrade.upgrade'))
+
+ await waitFor(() => {
+ expect(mockUpdateFromMarketPlace).toHaveBeenCalledWith({
+ original_plugin_unique_identifier: 'plugin@1.0.0',
+ new_plugin_unique_identifier: 'plugin@2.0.0',
+ })
+ expect(onSave).toHaveBeenCalled()
+ })
+ })
+
+ it('surfaces failed upgrade messages from the response task payload', async () => {
+ mockUpdateFromMarketPlace.mockResolvedValue({
+ task: {
+ status: TaskStatus.failed,
+ plugins: [{
+ plugin_unique_identifier: 'plugin@2.0.0',
+ message: 'upgrade failed',
+ }],
+ },
+ })
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('plugin.upgrade.upgrade'))
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalledWith('upgrade failed')
+ })
+ })
+
+ it('removes auto-upgrade before downgrading when the warning modal is shown', async () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('exclude and downgrade'))
+
+ await waitFor(() => {
+ expect(mockRemoveAutoUpgrade).toHaveBeenCalledWith({ plugin_id: 'plugin-1' })
+ expect(mockInvalidateReferenceSettings).toHaveBeenCalled()
+ expect(mockUpdateFromMarketPlace).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx
new file mode 100644
index 0000000000..b65c6a6e42
--- /dev/null
+++ b/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx
@@ -0,0 +1,107 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import PluginVersionPicker from '../plugin-version-picker'
+
+type VersionItem = {
+ version: string
+ unique_identifier: string
+ created_at: string
+}
+
+const mockVersionList = vi.hoisted(() => ({
+ data: {
+ versions: [] as VersionItem[],
+ },
+}))
+
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: () => ({
+ formatDate: (value: string, format: string) => `${value}:${format}`,
+ }),
+}))
+
+vi.mock('@/service/use-plugins', () => ({
+ useVersionListOfPlugin: () => ({
+ data: mockVersionList,
+ }),
+}))
+
+describe('PluginVersionPicker', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockVersionList.data.versions = [
+ {
+ version: '2.0.0',
+ unique_identifier: 'uid-current',
+ created_at: '2024-01-02',
+ },
+ {
+ version: '1.0.0',
+ unique_identifier: 'uid-old',
+ created_at: '2023-12-01',
+ },
+ ]
+ })
+
+ it('renders version options and highlights the current version', () => {
+ render(
+ trigger}
+ onSelect={vi.fn()}
+ />,
+ )
+
+ expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument()
+ expect(screen.getByText('2.0.0')).toBeInTheDocument()
+ expect(screen.getByText('2024-01-02:appLog.dateTimeFormat')).toBeInTheDocument()
+ expect(screen.getByText('CURRENT')).toBeInTheDocument()
+ })
+
+ it('calls onSelect with downgrade metadata and closes the picker', () => {
+ const onSelect = vi.fn()
+ const onShowChange = vi.fn()
+
+ render(
+ trigger}
+ onSelect={onSelect}
+ />,
+ )
+
+ fireEvent.click(screen.getByText('1.0.0'))
+
+ expect(onSelect).toHaveBeenCalledWith({
+ version: '1.0.0',
+ unique_identifier: 'uid-old',
+ isDowngrade: true,
+ })
+ expect(onShowChange).toHaveBeenCalledWith(false)
+ })
+
+ it('does not call onSelect when the current version is clicked', () => {
+ const onSelect = vi.fn()
+
+ render(
+ trigger}
+ onSelect={onSelect}
+ />,
+ )
+
+ fireEvent.click(screen.getByText('2.0.0'))
+
+ expect(onSelect).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx
new file mode 100644
index 0000000000..fcb208fc67
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx
@@ -0,0 +1,141 @@
+import type { EnvironmentVariable } from '@/app/components/workflow/types'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
+import RagPipelineChildren from '../rag-pipeline-children'
+
+let mockShowImportDSLModal = false
+let mockSubscription: ((value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null
+
+const {
+ mockSetShowImportDSLModal,
+ mockHandlePaneContextmenuCancel,
+ mockExportCheck,
+ mockHandleExportDSL,
+ mockUseRagPipelineSearch,
+} = vi.hoisted(() => ({
+ mockSetShowImportDSLModal: vi.fn((value: boolean) => {
+ mockShowImportDSLModal = value
+ }),
+ mockHandlePaneContextmenuCancel: vi.fn(),
+ mockExportCheck: vi.fn(),
+ mockHandleExportDSL: vi.fn(),
+ mockUseRagPipelineSearch: vi.fn(),
+}))
+
+vi.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: () => ({
+ eventEmitter: {
+ useSubscription: (callback: (value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => {
+ mockSubscription = callback
+ },
+ },
+ }),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: {
+ showImportDSLModal: boolean
+ setShowImportDSLModal: typeof mockSetShowImportDSLModal
+ }) => unknown) => selector({
+ showImportDSLModal: mockShowImportDSLModal,
+ setShowImportDSLModal: mockSetShowImportDSLModal,
+ }),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useDSL: () => ({
+ exportCheck: mockExportCheck,
+ handleExportDSL: mockHandleExportDSL,
+ }),
+ usePanelInteractions: () => ({
+ handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
+ }),
+}))
+
+vi.mock('../../hooks/use-rag-pipeline-search', () => ({
+ useRagPipelineSearch: mockUseRagPipelineSearch,
+}))
+
+vi.mock('../../../workflow/plugin-dependency', () => ({
+ default: () => ,
+}))
+
+vi.mock('../panel', () => ({
+ default: () => ,
+}))
+
+vi.mock('../publish-toast', () => ({
+ default: () => ,
+}))
+
+vi.mock('../rag-pipeline-header', () => ({
+ default: () => ,
+}))
+
+vi.mock('../update-dsl-modal', () => ({
+ default: ({ onCancel }: { onCancel: () => void }) => (
+
+
+
+ ),
+}))
+
+vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
+ default: ({
+ envList,
+ onConfirm,
+ onClose,
+ }: {
+ envList: EnvironmentVariable[]
+ onConfirm: () => void
+ onClose: () => void
+ }) => (
+
+
{envList.map(env => env.name).join(',')}
+
+
+
+ ),
+}))
+
+describe('RagPipelineChildren', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockShowImportDSLModal = false
+ mockSubscription = null
+ })
+
+ it('should render the main pipeline children and the import modal when enabled', () => {
+ mockShowImportDSLModal = true
+
+ render()
+
+ fireEvent.click(screen.getByText('close import'))
+
+ expect(mockUseRagPipelineSearch).toHaveBeenCalledTimes(1)
+ expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument()
+ expect(screen.getByTestId('rag-header')).toBeInTheDocument()
+ expect(screen.getByTestId('rag-panel')).toBeInTheDocument()
+ expect(screen.getByTestId('publish-toast')).toBeInTheDocument()
+ expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument()
+ expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false)
+ })
+
+ it('should show the DSL export confirmation modal after receiving the export event', () => {
+ render()
+
+ act(() => {
+ mockSubscription?.({
+ type: DSL_EXPORT_CHECK,
+ payload: {
+ data: [{ name: 'API_KEY' } as EnvironmentVariable],
+ },
+ })
+ })
+
+ fireEvent.click(screen.getByText('confirm export'))
+
+ expect(screen.getByTestId('dsl-export-modal')).toHaveTextContent('API_KEY')
+ expect(mockHandleExportDSL).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx
new file mode 100644
index 0000000000..1854b2a683
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react'
+import PipelineScreenShot from '../screenshot'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({
+ theme: 'dark',
+ }),
+}))
+
+vi.mock('@/utils/var', () => ({
+ basePath: '/console',
+}))
+
+describe('PipelineScreenShot', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should build themed screenshot sources', () => {
+ const { container } = render()
+ const sources = container.querySelectorAll('source')
+
+ expect(sources).toHaveLength(3)
+ expect(sources[0]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline.png')
+ expect(sources[1]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@2x.png')
+ expect(sources[2]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@3x.png')
+ expect(screen.getByAltText('Pipeline Screenshot')).toHaveAttribute('src', '/console/screenshots/dark/Pipeline.png')
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx
new file mode 100644
index 0000000000..43dffb80f9
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react'
+import QAItem from '../q-a-item'
+import { QAItemType } from '../types'
+
+describe('QAItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the question prefix', () => {
+ render()
+
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('What is Dify?')).toBeInTheDocument()
+ })
+
+ it('should render the answer prefix', () => {
+ render()
+
+ expect(screen.getByText('A')).toBeInTheDocument()
+ expect(screen.getByText('An LLM app platform.')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts
new file mode 100644
index 0000000000..e4e53a4c5b
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts
@@ -0,0 +1,97 @@
+import { SupportUploadFileTypes } from '@/app/components/workflow/types'
+import { VAR_ITEM_TEMPLATE_IN_PIPELINE } from '@/config'
+import { PipelineInputVarType } from '@/models/pipeline'
+import { TransferMethod } from '@/types/app'
+import { convertFormDataToINputField, convertToInputFieldFormData } from '../utils'
+
+describe('input-field editor utils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should convert pipeline input vars into form data', () => {
+ const result = convertToInputFieldFormData({
+ type: PipelineInputVarType.multiFiles,
+ label: 'Upload files',
+ variable: 'documents',
+ max_length: 5,
+ default_value: 'initial-value',
+ required: false,
+ tooltips: 'Tooltip text',
+ options: ['a', 'b'],
+ placeholder: 'Select files',
+ unit: 'MB',
+ allowed_file_upload_methods: [TransferMethod.local_file],
+ allowed_file_types: [SupportUploadFileTypes.document],
+ allowed_file_extensions: ['pdf'],
+ })
+
+ expect(result).toEqual({
+ type: PipelineInputVarType.multiFiles,
+ label: 'Upload files',
+ variable: 'documents',
+ maxLength: 5,
+ default: 'initial-value',
+ required: false,
+ tooltips: 'Tooltip text',
+ options: ['a', 'b'],
+ placeholder: 'Select files',
+ unit: 'MB',
+ allowedFileUploadMethods: [TransferMethod.local_file],
+ allowedTypesAndExtensions: {
+ allowedFileTypes: [SupportUploadFileTypes.document],
+ allowedFileExtensions: ['pdf'],
+ },
+ })
+ })
+
+ it('should fall back to the default input variable template', () => {
+ const result = convertToInputFieldFormData()
+
+ expect(result).toEqual({
+ type: VAR_ITEM_TEMPLATE_IN_PIPELINE.type,
+ label: VAR_ITEM_TEMPLATE_IN_PIPELINE.label,
+ variable: VAR_ITEM_TEMPLATE_IN_PIPELINE.variable,
+ maxLength: undefined,
+ required: VAR_ITEM_TEMPLATE_IN_PIPELINE.required,
+ options: VAR_ITEM_TEMPLATE_IN_PIPELINE.options,
+ allowedTypesAndExtensions: {},
+ })
+ })
+
+ it('should convert form data back into pipeline input variables', () => {
+ const result = convertFormDataToINputField({
+ type: PipelineInputVarType.select,
+ label: 'Category',
+ variable: 'category',
+ maxLength: 10,
+ default: 'books',
+ required: true,
+ tooltips: 'Pick one',
+ options: ['books', 'music'],
+ placeholder: 'Choose',
+ unit: '',
+ allowedFileUploadMethods: [TransferMethod.local_file],
+ allowedTypesAndExtensions: {
+ allowedFileTypes: [SupportUploadFileTypes.document],
+ allowedFileExtensions: ['txt'],
+ },
+ })
+
+ expect(result).toEqual({
+ type: PipelineInputVarType.select,
+ label: 'Category',
+ variable: 'category',
+ max_length: 10,
+ default_value: 'books',
+ required: true,
+ tooltips: 'Pick one',
+ options: ['books', 'music'],
+ placeholder: 'Choose',
+ unit: '',
+ allowed_file_upload_methods: [TransferMethod.local_file],
+ allowed_file_types: [SupportUploadFileTypes.document],
+ allowed_file_extensions: ['txt'],
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx
new file mode 100644
index 0000000000..0a5b748c7b
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx
@@ -0,0 +1,73 @@
+import type { InputFieldFormProps } from '../types'
+import { render, screen } from '@testing-library/react'
+import { useAppForm } from '@/app/components/base/form'
+import HiddenFields from '../hidden-fields'
+import { useHiddenConfigurations } from '../hooks'
+
+const { mockInputField } = vi.hoisted(() => ({
+ mockInputField: vi.fn(({ config }: { config: { variable: string } }) => {
+ return function FieldComponent() {
+ return {config.variable}
+ }
+ }),
+}))
+
+vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({
+ default: mockInputField,
+}))
+
+vi.mock('../hooks', () => ({
+ useHiddenConfigurations: vi.fn(),
+}))
+
+describe('HiddenFields', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should build fields from the hidden configuration list', () => {
+ vi.mocked(useHiddenConfigurations).mockReturnValue([
+ { variable: 'default' },
+ { variable: 'tooltips' },
+ ] as ReturnType)
+
+ const HiddenFieldsHarness = () => {
+ const initialData: InputFieldFormProps['initialData'] = {
+ variable: 'field_1',
+ options: ['option-a', 'option-b'],
+ }
+ const form = useAppForm({
+ defaultValues: initialData,
+ onSubmit: () => {},
+ })
+ const HiddenFieldsComp = HiddenFields({ initialData })
+ return
+ }
+ render()
+
+ expect(useHiddenConfigurations).toHaveBeenCalledWith({
+ options: ['option-a', 'option-b'],
+ })
+ expect(mockInputField).toHaveBeenCalledTimes(2)
+ expect(screen.getAllByTestId('input-field')).toHaveLength(2)
+ expect(screen.getByText('default')).toBeInTheDocument()
+ expect(screen.getByText('tooltips')).toBeInTheDocument()
+ })
+
+ it('should render nothing when there are no hidden configurations', () => {
+ vi.mocked(useHiddenConfigurations).mockReturnValue([])
+
+ const HiddenFieldsHarness = () => {
+ const initialData: InputFieldFormProps['initialData'] = { options: [] }
+ const form = useAppForm({
+ defaultValues: initialData,
+ onSubmit: () => {},
+ })
+ const HiddenFieldsComp = HiddenFields({ initialData })
+ return
+ }
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx
new file mode 100644
index 0000000000..e6bf21ed74
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx
@@ -0,0 +1,85 @@
+import type { ComponentType } from 'react'
+import { render, screen } from '@testing-library/react'
+import { useConfigurations } from '../hooks'
+import InitialFields from '../initial-fields'
+
+type MockForm = {
+ store: object
+ getFieldValue: (fieldName: string) => unknown
+ setFieldValue: (fieldName: string, value: unknown) => void
+}
+
+const {
+ mockForm,
+ mockInputField,
+} = vi.hoisted(() => ({
+ mockForm: {
+ store: {},
+ getFieldValue: vi.fn(),
+ setFieldValue: vi.fn(),
+ } as MockForm,
+ mockInputField: vi.fn(({ config }: { config: { variable: string } }) => {
+ return function FieldComponent() {
+ return {config.variable}
+ }
+ }),
+}))
+
+vi.mock('@/app/components/base/form', () => ({
+ withForm: ({ render }: {
+ render: (props: { form: MockForm }) => React.ReactNode
+ }) => ({ form }: { form?: MockForm }) => render({ form: form ?? mockForm }),
+}))
+
+vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({
+ default: mockInputField,
+}))
+
+vi.mock('../hooks', () => ({
+ useConfigurations: vi.fn(),
+}))
+
+describe('InitialFields', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should build initial fields with the form accessors and supportFile flag', () => {
+ vi.mocked(useConfigurations).mockReturnValue([
+ { variable: 'type' },
+ { variable: 'label' },
+ ] as ReturnType)
+
+ const InitialFieldsComp = InitialFields({
+ initialData: { variable: 'field_1' },
+ supportFile: true,
+ }) as unknown as ComponentType
+ render()
+
+ expect(useConfigurations).toHaveBeenCalledWith(expect.objectContaining({
+ supportFile: true,
+ getFieldValue: expect.any(Function),
+ setFieldValue: expect.any(Function),
+ }))
+ expect(screen.getAllByTestId('input-field')).toHaveLength(2)
+ expect(screen.getByText('type')).toBeInTheDocument()
+ expect(screen.getByText('label')).toBeInTheDocument()
+ })
+
+ it('should delegate field accessors to the underlying form instance', () => {
+ vi.mocked(useConfigurations).mockReturnValue([] as ReturnType)
+ mockForm.getFieldValue = vi.fn(() => 'label-value')
+ mockForm.setFieldValue = vi.fn()
+
+ const InitialFieldsComp = InitialFields({ supportFile: false }) as unknown as ComponentType
+ render()
+
+ const call = vi.mocked(useConfigurations).mock.calls[0]?.[0]
+ const value = call?.getFieldValue('label')
+ call?.setFieldValue('label', 'next-value')
+
+ expect(value).toBe('label-value')
+ expect(mockForm.getFieldValue).toHaveBeenCalledWith('label')
+ expect(mockForm.setFieldValue).toHaveBeenCalledWith('label', 'next-value')
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx
new file mode 100644
index 0000000000..9dd943f969
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx
@@ -0,0 +1,62 @@
+import type { InputFieldFormProps } from '../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useAppForm } from '@/app/components/base/form'
+import { PipelineInputVarType } from '@/models/pipeline'
+import { useHiddenFieldNames } from '../hooks'
+import ShowAllSettings from '../show-all-settings'
+
+vi.mock('../hooks', () => ({
+ useHiddenFieldNames: vi.fn(),
+}))
+
+describe('ShowAllSettings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useHiddenFieldNames).mockReturnValue('default value, placeholder')
+ })
+
+ it('should render the summary and hidden field names', () => {
+ const ShowAllSettingsHarness = () => {
+ const initialData: InputFieldFormProps['initialData'] = {
+ type: PipelineInputVarType.textInput,
+ }
+ const form = useAppForm({
+ defaultValues: initialData,
+ onSubmit: () => {},
+ })
+ const ShowAllSettingsComp = ShowAllSettings({
+ initialData,
+ handleShowAllSettings: vi.fn(),
+ })
+ return
+ }
+ render()
+
+ expect(useHiddenFieldNames).toHaveBeenCalledWith(PipelineInputVarType.textInput)
+ expect(screen.getByText('appDebug.variableConfig.showAllSettings')).toBeInTheDocument()
+ expect(screen.getByText('default value, placeholder')).toBeInTheDocument()
+ })
+
+ it('should call the click handler when the row is pressed', () => {
+ const handleShowAllSettings = vi.fn()
+ const ShowAllSettingsHarness = () => {
+ const initialData: InputFieldFormProps['initialData'] = {
+ type: PipelineInputVarType.textInput,
+ }
+ const form = useAppForm({
+ defaultValues: initialData,
+ onSubmit: () => {},
+ })
+ const ShowAllSettingsComp = ShowAllSettings({
+ initialData,
+ handleShowAllSettings,
+ })
+ return
+ }
+ render()
+
+ fireEvent.click(screen.getByText('appDebug.variableConfig.showAllSettings'))
+
+ expect(handleShowAllSettings).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx
new file mode 100644
index 0000000000..4a738761d0
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx
@@ -0,0 +1,83 @@
+import type { InputVar } from '@/models/pipeline'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { PipelineInputVarType } from '@/models/pipeline'
+import FieldItem from '../field-item'
+
+const createInputVar = (overrides: Partial = {}): InputVar => ({
+ type: PipelineInputVarType.textInput,
+ label: 'Field Label',
+ variable: 'field_name',
+ max_length: 48,
+ default_value: '',
+ required: true,
+ tooltips: '',
+ options: [],
+ placeholder: '',
+ unit: '',
+ allowed_file_upload_methods: [],
+ allowed_file_types: [],
+ allowed_file_extensions: [],
+ ...overrides,
+})
+
+describe('FieldItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the variable, label, and required badge', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('field_name')).toBeInTheDocument()
+ expect(screen.getByText('Field Label')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.start.required')).toBeInTheDocument()
+ })
+
+ it('should show edit and delete controls on hover and trigger both callbacks', () => {
+ const onClickEdit = vi.fn()
+ const onRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ fireEvent.mouseEnter(container.firstChild!)
+ const buttons = screen.getAllByRole('button')
+ fireEvent.click(buttons[0])
+ fireEvent.click(buttons[1])
+
+ expect(onClickEdit).toHaveBeenCalledWith('custom_field')
+ expect(onRemove).toHaveBeenCalledWith(2)
+ })
+
+ it('should keep the row readonly when readonly is enabled', () => {
+ const onClickEdit = vi.fn()
+ const onRemove = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ fireEvent.mouseEnter(container.firstChild!)
+
+ expect(screen.queryAllByRole('button')).toHaveLength(0)
+ expect(onClickEdit).not.toHaveBeenCalled()
+ expect(onRemove).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx
new file mode 100644
index 0000000000..5e49a4c9b4
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx
@@ -0,0 +1,60 @@
+import type { InputVar } from '@/models/pipeline'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { PipelineInputVarType } from '@/models/pipeline'
+import FieldListContainer from '../field-list-container'
+
+const createInputVar = (variable: string): InputVar => ({
+ type: PipelineInputVarType.textInput,
+ label: variable,
+ variable,
+ max_length: 48,
+ default_value: '',
+ required: true,
+ tooltips: '',
+ options: [],
+ placeholder: '',
+ unit: '',
+ allowed_file_upload_methods: [],
+ allowed_file_types: [],
+ allowed_file_extensions: [],
+})
+
+describe('FieldListContainer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the field items inside the sortable container', () => {
+ const onListSortChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ expect(screen.getAllByText('field_1').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('field_2').length).toBeGreaterThan(0)
+ expect(container.querySelector('.handle')).toBeInTheDocument()
+ expect(onListSortChange).not.toHaveBeenCalled()
+ })
+
+ it('should honor readonly mode for the rendered field rows', () => {
+ const { container } = render(
+ ,
+ )
+
+ const firstRow = container.querySelector('.handle')
+ fireEvent.mouseEnter(firstRow!)
+
+ expect(screen.queryAllByRole('button')).toHaveLength(0)
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx
new file mode 100644
index 0000000000..b0ab5d5312
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx
@@ -0,0 +1,24 @@
+import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
+import { render, screen } from '@testing-library/react'
+import Datasource from '../datasource'
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useToolIcon: () => 'tool-icon',
+}))
+
+vi.mock('@/app/components/workflow/block-icon', () => ({
+ default: ({ toolIcon }: { toolIcon: string }) => {toolIcon}
,
+}))
+
+describe('Datasource', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the datasource title and icon', () => {
+ render()
+
+ expect(screen.getByTestId('block-icon')).toHaveTextContent('tool-icon')
+ expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx
new file mode 100644
index 0000000000..602a8a4708
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react'
+import GlobalInputs from '../global-inputs'
+
+vi.mock('@/app/components/base/tooltip', () => ({
+ default: ({
+ popupContent,
+ }: {
+ popupContent: React.ReactNode
+ }) => {popupContent}
,
+}))
+
+describe('GlobalInputs', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the title and tooltip copy', () => {
+ render()
+
+ expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument()
+ expect(screen.getByTestId('tooltip')).toHaveTextContent('datasetPipeline.inputFieldPanel.globalInputs.tooltip')
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx
new file mode 100644
index 0000000000..04701aeba4
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx
@@ -0,0 +1,73 @@
+import type { Datasource } from '../../../test-run/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import DataSource from '../data-source'
+
+const {
+ mockOnSelect,
+ mockUseDraftPipelinePreProcessingParams,
+} = vi.hoisted(() => ({
+ mockOnSelect: vi.fn(),
+ mockUseDraftPipelinePreProcessingParams: vi.fn(() => ({
+ data: {
+ variables: [{ variable: 'source' }],
+ },
+ })),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }),
+}))
+
+vi.mock('@/service/use-pipeline', () => ({
+ useDraftPipelinePreProcessingParams: mockUseDraftPipelinePreProcessingParams,
+}))
+
+vi.mock('../../../test-run/preparation/data-source-options', () => ({
+ default: ({
+ onSelect,
+ dataSourceNodeId,
+ }: {
+ onSelect: (data: Datasource) => void
+ dataSourceNodeId: string
+ }) => (
+
+
+
+ ),
+}))
+
+vi.mock('../form', () => ({
+ default: ({ variables }: { variables: Array<{ variable: string }> }) => (
+ {variables.map(item => item.variable).join(',')}
+ ),
+}))
+
+describe('DataSource preview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the datasource selection step and forward selected values', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('select datasource'))
+
+ expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle')).toBeInTheDocument()
+ expect(screen.getByTestId('data-source-options')).toHaveAttribute('data-node-id', 'node-1')
+ expect(screen.getByTestId('preview-form')).toHaveTextContent('source')
+ expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalledWith({
+ pipeline_id: 'pipeline-1',
+ node_id: 'node-1',
+ }, true)
+ expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'source-node' })
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx
new file mode 100644
index 0000000000..66299e112f
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx
@@ -0,0 +1,64 @@
+import type { RAGPipelineVariables } from '@/models/pipeline'
+import { render, screen } from '@testing-library/react'
+import Form from '../form'
+
+type MockForm = {
+ id: string
+}
+
+const {
+ mockForm,
+ mockBaseField,
+ mockUseInitialData,
+ mockUseConfigurations,
+} = vi.hoisted(() => ({
+ mockForm: {
+ id: 'form-1',
+ } as MockForm,
+ mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => {
+ return function FieldComponent() {
+ return {config.variable}
+ }
+ }),
+ mockUseInitialData: vi.fn(() => ({ source: 'node-1' })),
+ mockUseConfigurations: vi.fn(() => [{ variable: 'source' }, { variable: 'chunkSize' }]),
+}))
+
+vi.mock('@/app/components/base/form', () => ({
+ useAppForm: () => mockForm,
+}))
+
+vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({
+ default: mockBaseField,
+}))
+
+vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({
+ useInitialData: mockUseInitialData,
+ useConfigurations: mockUseConfigurations,
+}))
+
+describe('Preview form', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should build fields from the pipeline variable configuration', () => {
+ render()
+
+ expect(mockUseInitialData).toHaveBeenCalled()
+ expect(mockUseConfigurations).toHaveBeenCalled()
+ expect(screen.getAllByTestId('base-field')).toHaveLength(2)
+ expect(screen.getByText('source')).toBeInTheDocument()
+ expect(screen.getByText('chunkSize')).toBeInTheDocument()
+ })
+
+ it('should prevent the native form submission', () => {
+ const { container } = render()
+ const form = container.querySelector('form')!
+ const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
+
+ form.dispatchEvent(submitEvent)
+
+ expect(submitEvent.defaultPrevented).toBe(true)
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx
new file mode 100644
index 0000000000..3e4944d775
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '@testing-library/react'
+import ProcessDocuments from '../process-documents'
+
+const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({
+ data: {
+ variables: [{ variable: 'chunkSize' }],
+ },
+})))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }),
+}))
+
+vi.mock('@/service/use-pipeline', () => ({
+ useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams,
+}))
+
+vi.mock('../form', () => ({
+ default: ({ variables }: { variables: Array<{ variable: string }> }) => (
+ {variables.map(item => item.variable).join(',')}
+ ),
+}))
+
+describe('ProcessDocuments preview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the processing step and its variables', () => {
+ render()
+
+ expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle')).toBeInTheDocument()
+ expect(screen.getByTestId('preview-form')).toHaveTextContent('chunkSize')
+ expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({
+ pipeline_id: 'pipeline-1',
+ node_id: 'node-2',
+ }, true)
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx
new file mode 100644
index 0000000000..8149bac144
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx
@@ -0,0 +1,60 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import Header from '../header'
+
+const {
+ mockSetIsPreparingDataSource,
+ mockHandleCancelDebugAndPreviewPanel,
+ mockWorkflowStore,
+} = vi.hoisted(() => ({
+ mockSetIsPreparingDataSource: vi.fn(),
+ mockHandleCancelDebugAndPreviewPanel: vi.fn(),
+ mockWorkflowStore: {
+ getState: vi.fn(() => ({
+ isPreparingDataSource: true,
+ setIsPreparingDataSource: vi.fn(),
+ })),
+ },
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => mockWorkflowStore,
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useWorkflowInteractions: () => ({
+ handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
+ }),
+}))
+
+describe('TestRun header', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockWorkflowStore.getState.mockReturnValue({
+ isPreparingDataSource: true,
+ setIsPreparingDataSource: mockSetIsPreparingDataSource,
+ })
+ })
+
+ it('should render the title and reset preparing state on close', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(screen.getByText('datasetPipeline.testRun.title')).toBeInTheDocument()
+ expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
+ expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should only cancel the panel when the datasource preparation flag is false', () => {
+ mockWorkflowStore.getState.mockReturnValue({
+ isPreparingDataSource: false,
+ setIsPreparingDataSource: mockSetIsPreparingDataSource,
+ })
+
+ render()
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(mockSetIsPreparingDataSource).not.toHaveBeenCalled()
+ expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx
new file mode 100644
index 0000000000..b4eab3fe72
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@testing-library/react'
+import FooterTips from '../footer-tips'
+
+describe('FooterTips', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the localized footer copy', () => {
+ render()
+
+ expect(screen.getByText('datasetPipeline.testRun.tooltip')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx
new file mode 100644
index 0000000000..d5985f2969
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react'
+import StepIndicator from '../step-indicator'
+
+describe('StepIndicator', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render all step labels and highlight the current step', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(screen.getByText('Select source')).toBeInTheDocument()
+ expect(screen.getByText('Process docs')).toBeInTheDocument()
+ expect(screen.getByText('Run test')).toBeInTheDocument()
+ expect(container.querySelector('.bg-state-accent-solid')).toBeInTheDocument()
+ expect(screen.getByText('Process docs').parentElement).toHaveClass('text-state-accent-solid')
+ })
+
+ it('should keep inactive steps in the tertiary state', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Process docs').parentElement).toHaveClass('text-text-tertiary')
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx
new file mode 100644
index 0000000000..83cb252943
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx
@@ -0,0 +1,49 @@
+import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import OptionCard from '../option-card'
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useToolIcon: () => 'source-icon',
+}))
+
+vi.mock('@/app/components/workflow/block-icon', () => ({
+ default: ({ toolIcon }: { toolIcon: string }) => {toolIcon}
,
+}))
+
+describe('OptionCard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the datasource label and icon', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('block-icon')).toHaveTextContent('source-icon')
+ expect(screen.getByText('Website Crawl')).toBeInTheDocument()
+ })
+
+ it('should call onClick with the card value and apply selected styles', () => {
+ const onClick = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('Online Drive'))
+
+ expect(onClick).toHaveBeenCalledWith('online-drive')
+ expect(screen.getByText('Online Drive')).toHaveClass('text-text-primary')
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx
new file mode 100644
index 0000000000..69f576eae7
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx
@@ -0,0 +1,67 @@
+import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+import Actions from '../actions'
+
+let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus } } | undefined
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { workflowRunningData: typeof mockWorkflowRunningData }) => unknown) => selector({
+ workflowRunningData: mockWorkflowRunningData,
+ }),
+}))
+
+const createFormParams = (overrides: Partial = {}): CustomActionsProps => ({
+ form: {
+ handleSubmit: vi.fn(),
+ } as unknown as CustomActionsProps['form'],
+ isSubmitting: false,
+ canSubmit: true,
+ ...overrides,
+})
+
+describe('Document processing actions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockWorkflowRunningData = undefined
+ })
+
+ it('should render back/process actions and trigger both callbacks', () => {
+ const onBack = vi.fn()
+ const formParams = createFormParams()
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.backToDataSource' }))
+ fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.process' }))
+
+ expect(onBack).toHaveBeenCalledTimes(1)
+ expect(formParams.form.handleSubmit).toHaveBeenCalledTimes(1)
+ })
+
+ it('should disable processing when runDisabled or the workflow is already running', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'datasetPipeline.operations.process' })).toBeDisabled()
+
+ mockWorkflowRunningData = {
+ result: {
+ status: WorkflowRunningStatus.Running,
+ },
+ }
+ rerender(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i })).toBeDisabled()
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts
new file mode 100644
index 0000000000..822d553732
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts
@@ -0,0 +1,32 @@
+import { renderHook } from '@testing-library/react'
+import { useInputVariables } from '../hooks'
+
+const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({
+ data: { variables: [{ variable: 'chunkSize' }] },
+ isFetching: true,
+})))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }),
+}))
+
+vi.mock('@/service/use-pipeline', () => ({
+ useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams,
+}))
+
+describe('useInputVariables', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should query processing params with the current pipeline id and datasource node id', () => {
+ const { result } = renderHook(() => useInputVariables('datasource-node'))
+
+ expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({
+ pipeline_id: 'pipeline-1',
+ node_id: 'datasource-node',
+ })
+ expect(result.current.isFetchingParams).toBe(true)
+ expect(result.current.paramsConfig).toEqual({ variables: [{ variable: 'chunkSize' }] })
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx
new file mode 100644
index 0000000000..fcfa305bb3
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx
@@ -0,0 +1,140 @@
+import type { ZodSchema } from 'zod'
+import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions'
+import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import Options from '../options'
+
+const {
+ mockFormValue,
+ mockHandleSubmit,
+ mockToastError,
+ mockBaseField,
+} = vi.hoisted(() => ({
+ mockFormValue: { chunkSize: 256 } as Record,
+ mockHandleSubmit: vi.fn(),
+ mockToastError: vi.fn(),
+ mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => {
+ return function FieldComponent() {
+ return {config.variable}
+ }
+ }),
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ error: mockToastError,
+ },
+}))
+
+vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({
+ default: mockBaseField,
+}))
+
+vi.mock('@/app/components/base/form', () => ({
+ useAppForm: ({
+ onSubmit,
+ validators,
+ }: {
+ onSubmit: (params: { value: Record }) => void
+ validators?: {
+ onSubmit?: (params: { value: Record }) => string | undefined
+ }
+ }) => ({
+ handleSubmit: () => {
+ const validationResult = validators?.onSubmit?.({ value: mockFormValue })
+ if (!validationResult)
+ onSubmit({ value: mockFormValue })
+ mockHandleSubmit()
+ },
+ AppForm: ({ children }: { children: React.ReactNode }) => {children}
,
+ Actions: ({ CustomActions }: { CustomActions: (props: CustomActionsProps) => React.ReactNode }) => (
+
+ {CustomActions({
+ form: {
+ handleSubmit: mockHandleSubmit,
+ } as unknown as CustomActionsProps['form'],
+ isSubmitting: false,
+ canSubmit: true,
+ })}
+
+ ),
+ }),
+}))
+
+const createSchema = (success: boolean): ZodSchema => ({
+ safeParse: vi.fn(() => {
+ if (success)
+ return { success: true }
+
+ return {
+ success: false,
+ error: {
+ issues: [{
+ path: ['chunkSize'],
+ message: 'Invalid value',
+ }],
+ },
+ }
+ }),
+}) as unknown as ZodSchema
+
+describe('Document processing options', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render base fields and the custom actions slot', () => {
+ render(
+ custom actions
}
+ onSubmit={vi.fn()}
+ />,
+ )
+
+ expect(screen.getByTestId('base-field')).toHaveTextContent('chunkSize')
+ expect(screen.getByTestId('form-actions')).toBeInTheDocument()
+ expect(screen.getByTestId('custom-actions')).toBeInTheDocument()
+ })
+
+ it('should validate and toast the first schema error before submitting', async () => {
+ const onSubmit = vi.fn()
+ const { container } = render(
+ actions
}
+ onSubmit={onSubmit}
+ />,
+ )
+
+ fireEvent.submit(container.querySelector('form')!)
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalledWith('Path: chunkSize Error: Invalid value')
+ })
+ expect(onSubmit).not.toHaveBeenCalled()
+ })
+
+ it('should submit the parsed form value when validation succeeds', async () => {
+ const onSubmit = vi.fn()
+ const { container } = render(
+ actions
}
+ onSubmit={onSubmit}
+ />,
+ )
+
+ fireEvent.submit(container.querySelector('form')!)
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith(mockFormValue)
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts
new file mode 100644
index 0000000000..376b529d40
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts
@@ -0,0 +1,84 @@
+import { ChunkingMode } from '@/models/datasets'
+import { formatPreviewChunks } from '../utils'
+
+vi.mock('@/config', () => ({
+ RAG_PIPELINE_PREVIEW_CHUNK_NUM: 2,
+}))
+
+describe('result preview utils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should return undefined for empty outputs', () => {
+ expect(formatPreviewChunks(undefined)).toBeUndefined()
+ expect(formatPreviewChunks(null)).toBeUndefined()
+ })
+
+ it('should format text chunks and limit them to the preview length', () => {
+ const result = formatPreviewChunks({
+ chunk_structure: ChunkingMode.text,
+ preview: [
+ { content: 'Chunk 1', summary: 'S1' },
+ { content: 'Chunk 2', summary: 'S2' },
+ { content: 'Chunk 3', summary: 'S3' },
+ ],
+ })
+
+ expect(result).toEqual([
+ { content: 'Chunk 1', summary: 'S1' },
+ { content: 'Chunk 2', summary: 'S2' },
+ ])
+ })
+
+ it('should format paragraph and full-doc parent-child previews differently', () => {
+ const paragraph = formatPreviewChunks({
+ chunk_structure: ChunkingMode.parentChild,
+ parent_mode: 'paragraph',
+ preview: [
+ { content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] },
+ { content: 'Parent 2', child_chunks: ['c4'] },
+ { content: 'Parent 3', child_chunks: ['c5'] },
+ ],
+ })
+ const fullDoc = formatPreviewChunks({
+ chunk_structure: ChunkingMode.parentChild,
+ parent_mode: 'full-doc',
+ preview: [
+ { content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] },
+ ],
+ })
+
+ expect(paragraph).toEqual({
+ parent_mode: 'paragraph',
+ parent_child_chunks: [
+ { parent_content: 'Parent 1', parent_summary: undefined, child_contents: ['c1', 'c2', 'c3'], parent_mode: 'paragraph' },
+ { parent_content: 'Parent 2', parent_summary: undefined, child_contents: ['c4'], parent_mode: 'paragraph' },
+ ],
+ })
+ expect(fullDoc).toEqual({
+ parent_mode: 'full-doc',
+ parent_child_chunks: [
+ { parent_content: 'Parent 1', child_contents: ['c1', 'c2'], parent_mode: 'full-doc' },
+ ],
+ })
+ })
+
+ it('should format qa previews and limit them to the preview size', () => {
+ const result = formatPreviewChunks({
+ chunk_structure: ChunkingMode.qa,
+ qa_preview: [
+ { question: 'Q1', answer: 'A1' },
+ { question: 'Q2', answer: 'A2' },
+ { question: 'Q3', answer: 'A3' },
+ ],
+ })
+
+ expect(result).toEqual({
+ qa_chunks: [
+ { question: 'Q1', answer: 'A1' },
+ { question: 'Q2', answer: 'A2' },
+ ],
+ })
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx
new file mode 100644
index 0000000000..0597bc3de8
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx
@@ -0,0 +1,64 @@
+import type { WorkflowRunningData } from '@/app/components/workflow/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import Tab from '../tab'
+
+const createWorkflowRunningData = (): WorkflowRunningData => ({
+ task_id: 'task-1',
+ message_id: 'message-1',
+ conversation_id: 'conversation-1',
+ result: {
+ workflow_id: 'workflow-1',
+ inputs: '{}',
+ inputs_truncated: false,
+ process_data: '{}',
+ process_data_truncated: false,
+ outputs: '{}',
+ outputs_truncated: false,
+ status: 'succeeded',
+ elapsed_time: 10,
+ total_tokens: 20,
+ created_at: Date.now(),
+ finished_at: Date.now(),
+ steps: 1,
+ total_steps: 1,
+ },
+ tracing: [],
+})
+
+describe('Tab', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render an active tab and pass its value on click', () => {
+ const onClick = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
+
+ expect(screen.getByRole('button')).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
+ expect(onClick).toHaveBeenCalledWith('preview')
+ })
+
+ it('should disable the tab when workflow run data is unavailable', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'Trace' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Trace' })).toHaveClass('opacity-30')
+ })
+})
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx
new file mode 100644
index 0000000000..493f3c3014
--- /dev/null
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx
@@ -0,0 +1,35 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import InputFieldButton from '../input-field-button'
+
+const {
+ mockSetShowInputFieldPanel,
+ mockSetShowEnvPanel,
+} = vi.hoisted(() => ({
+ mockSetShowInputFieldPanel: vi.fn(),
+ mockSetShowEnvPanel: vi.fn(),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: {
+ setShowInputFieldPanel: typeof mockSetShowInputFieldPanel
+ setShowEnvPanel: typeof mockSetShowEnvPanel
+ }) => unknown) => selector({
+ setShowInputFieldPanel: mockSetShowInputFieldPanel,
+ setShowEnvPanel: mockSetShowEnvPanel,
+ }),
+}))
+
+describe('InputFieldButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should open the input field panel and close the env panel', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.inputField' }))
+
+ expect(mockSetShowInputFieldPanel).toHaveBeenCalledWith(true)
+ expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts b/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts
new file mode 100644
index 0000000000..c90e702d8e
--- /dev/null
+++ b/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts
@@ -0,0 +1,92 @@
+import type { Viewport } from 'reactflow'
+import type { Node } from '@/app/components/workflow/types'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { processNodesWithoutDataSource } from '../nodes'
+
+vi.mock('@/app/components/workflow/constants', () => ({
+ CUSTOM_NODE: 'custom',
+ NODE_WIDTH_X_OFFSET: 400,
+ START_INITIAL_POSITION: { x: 100, y: 100 },
+}))
+
+vi.mock('@/app/components/workflow/nodes/data-source-empty/constants', () => ({
+ CUSTOM_DATA_SOURCE_EMPTY_NODE: 'data-source-empty',
+}))
+
+vi.mock('@/app/components/workflow/note-node/constants', () => ({
+ CUSTOM_NOTE_NODE: 'note',
+}))
+
+vi.mock('@/app/components/workflow/note-node/types', () => ({
+ NoteTheme: { blue: 'blue' },
+}))
+
+vi.mock('@/app/components/workflow/utils', () => ({
+ generateNewNode: ({ id, type, data, position }: { id: string, type: string, data: object, position: { x: number, y: number } }) => ({
+ newNode: { id, type, data, position },
+ }),
+}))
+
+describe('processNodesWithoutDataSource', () => {
+ it('should return the original nodes when a datasource node already exists', () => {
+ const nodes = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.DataSource },
+ position: { x: 100, y: 100 },
+ },
+ ] as Node[]
+ const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
+
+ const result = processNodesWithoutDataSource(nodes, viewport)
+
+ expect(result.nodes).toBe(nodes)
+ expect(result.viewport).toBe(viewport)
+ })
+
+ it('should prepend datasource empty and note nodes when the pipeline starts without a datasource', () => {
+ const nodes = [
+ {
+ id: 'node-1',
+ type: 'custom',
+ data: { type: BlockEnum.KnowledgeBase },
+ position: { x: 300, y: 200 },
+ },
+ ] as Node[]
+
+ const result = processNodesWithoutDataSource(nodes, { x: 0, y: 0, zoom: 2 })
+
+ expect(result.nodes[0]).toEqual(expect.objectContaining({
+ id: 'data-source-empty',
+ type: 'data-source-empty',
+ position: { x: -100, y: 200 },
+ }))
+ expect(result.nodes[1]).toEqual(expect.objectContaining({
+ id: 'note',
+ type: 'note',
+ position: { x: -100, y: 300 },
+ }))
+ expect(result.viewport).toEqual({
+ x: 400,
+ y: -200,
+ zoom: 2,
+ })
+ })
+
+ it('should leave nodes unchanged when there is no custom node to anchor from', () => {
+ const nodes = [
+ {
+ id: 'node-1',
+ type: 'note',
+ data: { type: BlockEnum.Answer },
+ position: { x: 100, y: 100 },
+ },
+ ] as Node[]
+
+ const result = processNodesWithoutDataSource(nodes)
+
+ expect(result.nodes).toBe(nodes)
+ expect(result.viewport).toBeUndefined()
+ })
+})
diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts b/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts
new file mode 100644
index 0000000000..6fe3576c26
--- /dev/null
+++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+import examples from '../examples'
+
+describe('edit-custom-collection examples', () => {
+ it('provides json, yaml, and blank templates in fixed order', () => {
+ expect(examples.map(example => example.key)).toEqual([
+ 'json',
+ 'yaml',
+ 'blankTemplate',
+ ])
+ })
+
+ it('contains representative OpenAPI content for each template', () => {
+ expect(examples[0].content).toContain('"openapi": "3.1.0"')
+ expect(examples[1].content).toContain('openapi: "3.0.0"')
+ expect(examples[2].content).toContain('"title": "Untitled"')
+ })
+})
diff --git a/web/app/components/tools/labels/__tests__/constant.spec.ts b/web/app/components/tools/labels/__tests__/constant.spec.ts
new file mode 100644
index 0000000000..614476fb8c
--- /dev/null
+++ b/web/app/components/tools/labels/__tests__/constant.spec.ts
@@ -0,0 +1,33 @@
+import type { Label } from '../constant'
+import { describe, expect, it } from 'vitest'
+
+describe('tool label type contract', () => {
+ it('accepts string labels', () => {
+ const label: Label = {
+ name: 'agent',
+ label: 'Agent',
+ icon: 'robot',
+ }
+
+ expect(label).toEqual({
+ name: 'agent',
+ label: 'Agent',
+ icon: 'robot',
+ })
+ })
+
+ it('accepts i18n labels', () => {
+ const label: Label = {
+ name: 'workflow',
+ label: {
+ en_US: 'Workflow',
+ zh_Hans: '工作流',
+ },
+ }
+
+ expect(label.label).toEqual({
+ en_US: 'Workflow',
+ zh_Hans: '工作流',
+ })
+ })
+})
diff --git a/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts b/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts
new file mode 100644
index 0000000000..acf8aafdf8
--- /dev/null
+++ b/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts
@@ -0,0 +1,102 @@
+import type { TFunction } from 'i18next'
+import { describe, expect, it } from 'vitest'
+import { VarType } from '@/app/components/workflow/types'
+import {
+ buildWorkflowToolRequestPayload,
+ getReservedWorkflowOutputParameters,
+ getWorkflowOutputParameters,
+ hasReservedWorkflowOutputConflict,
+ isWorkflowToolNameValid,
+ RESERVED_WORKFLOW_OUTPUTS,
+} from '../helpers'
+
+describe('workflow-tool helpers', () => {
+ it('validates workflow tool names', () => {
+ expect(isWorkflowToolNameValid('')).toBe(true)
+ expect(isWorkflowToolNameValid('workflow_tool_1')).toBe(true)
+ expect(isWorkflowToolNameValid('workflow-tool')).toBe(false)
+ expect(isWorkflowToolNameValid('workflow tool')).toBe(false)
+ })
+
+ it('builds translated reserved workflow outputs', () => {
+ const t = ((key: string, options?: { ns?: string }) => `${options?.ns}:${key}`) as TFunction
+
+ expect(getReservedWorkflowOutputParameters(t)).toEqual([
+ {
+ ...RESERVED_WORKFLOW_OUTPUTS[0],
+ description: 'workflow:nodes.tool.outputVars.text',
+ },
+ {
+ ...RESERVED_WORKFLOW_OUTPUTS[1],
+ description: 'workflow:nodes.tool.outputVars.files.title',
+ },
+ {
+ ...RESERVED_WORKFLOW_OUTPUTS[2],
+ description: 'workflow:nodes.tool.outputVars.json',
+ },
+ ])
+ })
+
+ it('detects reserved output conflicts', () => {
+ expect(hasReservedWorkflowOutputConflict(RESERVED_WORKFLOW_OUTPUTS, 'text')).toBe(true)
+ expect(hasReservedWorkflowOutputConflict(RESERVED_WORKFLOW_OUTPUTS, 'custom')).toBe(false)
+ })
+
+ it('derives workflow output parameters from schema through helper wrapper', () => {
+ expect(getWorkflowOutputParameters([], {
+ type: 'object',
+ properties: {
+ text: {
+ type: VarType.string,
+ description: 'Result text',
+ },
+ },
+ })).toEqual([
+ {
+ name: 'text',
+ description: 'Result text',
+ type: VarType.string,
+ },
+ ])
+ })
+
+ it('builds workflow tool request payload', () => {
+ expect(buildWorkflowToolRequestPayload({
+ name: 'workflow_tool',
+ description: 'Workflow tool',
+ emoji: {
+ content: '🧠',
+ background: '#ffffff',
+ },
+ label: 'Workflow Tool',
+ labels: ['agent', 'workflow'],
+ parameters: [
+ {
+ name: 'question',
+ type: VarType.string,
+ required: true,
+ form: 'llm',
+ description: 'Question to ask',
+ },
+ ],
+ privacyPolicy: 'https://example.com/privacy',
+ })).toEqual({
+ name: 'workflow_tool',
+ description: 'Workflow tool',
+ icon: {
+ content: '🧠',
+ background: '#ffffff',
+ },
+ label: 'Workflow Tool',
+ labels: ['agent', 'workflow'],
+ parameters: [
+ {
+ name: 'question',
+ description: 'Question to ask',
+ form: 'llm',
+ },
+ ],
+ privacy_policy: 'https://example.com/privacy',
+ })
+ })
+})
diff --git a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..f3f229abea
--- /dev/null
+++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx
@@ -0,0 +1,200 @@
+import type { WorkflowToolModalPayload } from '../index'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import WorkflowToolAsModal from '../index'
+
+vi.mock('@/app/components/base/drawer-plus', () => ({
+ default: ({ isShow, onHide, title, body }: { isShow: boolean, onHide: () => void, title: string, body: React.ReactNode }) => (
+ isShow
+ ? (
+
+ {title}
+
+ {body}
+
+ )
+ : null
+ ),
+}))
+
+vi.mock('@/app/components/base/emoji-picker', () => ({
+ default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => (
+
+
+
+
+ ),
+}))
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: ({ onClick, icon }: { onClick?: () => void, icon: string }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/tools/labels/selector', () => ({
+ default: ({ value, onChange }: { value: string[], onChange: (labels: string[]) => void }) => (
+
+ {value.join(',')}
+
+
+ ),
+}))
+
+vi.mock('@/app/components/base/tooltip', () => ({
+ default: ({
+ children,
+ popupContent,
+ }: {
+ children?: React.ReactNode
+ popupContent?: React.ReactNode
+ }) => (
+
+ {children}
+ {popupContent}
+
+ ),
+}))
+
+vi.mock('../confirm-modal', () => ({
+ default: ({ show, onClose, onConfirm }: { show: boolean, onClose: () => void, onConfirm: () => void }) => (
+ show
+ ? (
+
+
+
+
+ )
+ : null
+ ),
+}))
+
+const mockToastNotify = vi.fn()
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ success: (message: string) => mockToastNotify({ type: 'success', message }),
+ error: (message: string) => mockToastNotify({ type: 'error', message }),
+ },
+}))
+
+vi.mock('@/app/components/plugins/hooks', () => ({
+ useTags: () => ({
+ tags: [
+ { name: 'label1', label: 'Label 1' },
+ { name: 'label2', label: 'Label 2' },
+ ],
+ }),
+}))
+
+const createPayload = (overrides: Partial = {}): WorkflowToolModalPayload => ({
+ icon: { content: '🔧', background: '#ffffff' },
+ label: 'My Tool',
+ name: 'my_tool',
+ description: 'Tool description',
+ parameters: [
+ { name: 'param1', description: 'Parameter 1', form: 'llm', required: true, type: 'string' },
+ ],
+ outputParameters: [
+ { name: 'output1', description: 'Output 1' },
+ { name: 'text', description: 'Reserved output duplicate' },
+ ],
+ labels: ['label1'],
+ privacy_policy: '',
+ workflow_app_id: 'workflow-app-1',
+ workflow_tool_id: 'workflow-tool-1',
+ ...overrides,
+})
+
+describe('WorkflowToolAsModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should create workflow tools with edited form values', async () => {
+ const user = userEvent.setup()
+ const onCreate = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.clear(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'))
+ await user.type(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'), 'Created Tool')
+ await user.click(screen.getByTestId('append-label'))
+ await user.click(screen.getByTestId('app-icon'))
+ await user.click(screen.getByTestId('select-emoji'))
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({
+ workflow_app_id: 'workflow-app-1',
+ label: 'Created Tool',
+ icon: { content: '🚀', background: '#000000' },
+ labels: ['label1', 'new-label'],
+ }))
+ })
+
+ it('should block invalid tool-call names before saving', async () => {
+ const user = userEvent.setup()
+ const onCreate = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ expect(onCreate).not.toHaveBeenCalled()
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ }))
+ })
+
+ it('should require confirmation before saving existing workflow tools', async () => {
+ const user = userEvent.setup()
+ const onSave = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+ expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
+
+ await user.click(screen.getByTestId('confirm-save'))
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
+ workflow_tool_id: 'workflow-tool-1',
+ name: 'my_tool',
+ }))
+ })
+ })
+
+ it('should show duplicate reserved output warnings', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getAllByText('tools.createTool.toolOutput.reservedParameterDuplicateTip').length).toBeGreaterThan(0)
+ })
+})
diff --git a/web/app/components/tools/workflow-tool/helpers.ts b/web/app/components/tools/workflow-tool/helpers.ts
new file mode 100644
index 0000000000..9af1107c80
--- /dev/null
+++ b/web/app/components/tools/workflow-tool/helpers.ts
@@ -0,0 +1,95 @@
+import type { TFunction } from 'i18next'
+import type {
+ Emoji,
+ WorkflowToolProviderOutputParameter,
+ WorkflowToolProviderOutputSchema,
+ WorkflowToolProviderParameter,
+ WorkflowToolProviderRequest,
+} from '../types'
+import { VarType } from '@/app/components/workflow/types'
+import { buildWorkflowOutputParameters } from './utils'
+
+export const RESERVED_WORKFLOW_OUTPUTS: WorkflowToolProviderOutputParameter[] = [
+ {
+ name: 'text',
+ description: '',
+ type: VarType.string,
+ reserved: true,
+ },
+ {
+ name: 'files',
+ description: '',
+ type: VarType.arrayFile,
+ reserved: true,
+ },
+ {
+ name: 'json',
+ description: '',
+ type: VarType.arrayObject,
+ reserved: true,
+ },
+]
+
+export const isWorkflowToolNameValid = (name: string) => {
+ if (name === '')
+ return true
+
+ return /^\w+$/.test(name)
+}
+
+export const getReservedWorkflowOutputParameters = (t: TFunction) => {
+ return RESERVED_WORKFLOW_OUTPUTS.map(output => ({
+ ...output,
+ description: output.name === 'text'
+ ? t('nodes.tool.outputVars.text', { ns: 'workflow' })
+ : output.name === 'files'
+ ? t('nodes.tool.outputVars.files.title', { ns: 'workflow' })
+ : t('nodes.tool.outputVars.json', { ns: 'workflow' }),
+ }))
+}
+
+export const hasReservedWorkflowOutputConflict = (
+ reservedOutputParameters: WorkflowToolProviderOutputParameter[],
+ name: string,
+) => {
+ return reservedOutputParameters.some(parameter => parameter.name === name)
+}
+
+export const getWorkflowOutputParameters = (
+ rawOutputParameters: WorkflowToolProviderOutputParameter[],
+ outputSchema?: WorkflowToolProviderOutputSchema,
+) => {
+ return buildWorkflowOutputParameters(rawOutputParameters, outputSchema)
+}
+
+export const buildWorkflowToolRequestPayload = ({
+ description,
+ emoji,
+ label,
+ labels,
+ name,
+ parameters,
+ privacyPolicy,
+}: {
+ description: string
+ emoji: Emoji
+ label: string
+ labels: string[]
+ name: string
+ parameters: WorkflowToolProviderParameter[]
+ privacyPolicy: string
+}): WorkflowToolProviderRequest & { label: string } => {
+ return {
+ name,
+ description,
+ icon: emoji,
+ label,
+ parameters: parameters.map(item => ({
+ name: item.name,
+ description: item.description,
+ form: item.form,
+ })),
+ labels,
+ privacy_policy: privacyPolicy,
+ }
+}
diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx
index 23329f6a2c..219a0d8f53 100644
--- a/web/app/components/tools/workflow-tool/index.tsx
+++ b/web/app/components/tools/workflow-tool/index.tsx
@@ -17,9 +17,14 @@ import { toast } from '@/app/components/base/ui/toast'
import LabelSelector from '@/app/components/tools/labels/selector'
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
-import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
-import { buildWorkflowOutputParameters } from './utils'
+import {
+ buildWorkflowToolRequestPayload,
+ getReservedWorkflowOutputParameters,
+ getWorkflowOutputParameters,
+ hasReservedWorkflowOutputConflict,
+ isWorkflowToolNameValid,
+} from './helpers'
export type WorkflowToolModalPayload = {
icon: Emoji
@@ -67,27 +72,14 @@ const WorkflowToolAsModal: FC = ({
const [parameters, setParameters] = useState(payload.parameters)
const rawOutputParameters = payload.outputParameters
const outputSchema = payload.tool?.output_schema
- const outputParameters = useMemo(() => buildWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema])
- const reservedOutputParameters: WorkflowToolProviderOutputParameter[] = [
- {
- name: 'text',
- description: t('nodes.tool.outputVars.text', { ns: 'workflow' }),
- type: VarType.string,
- reserved: true,
- },
- {
- name: 'files',
- description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }),
- type: VarType.arrayFile,
- reserved: true,
- },
- {
- name: 'json',
- description: t('nodes.tool.outputVars.json', { ns: 'workflow' }),
- type: VarType.arrayObject,
- reserved: true,
- },
- ]
+ const outputParameters = useMemo(
+ () => getWorkflowOutputParameters(rawOutputParameters, outputSchema),
+ [rawOutputParameters, outputSchema],
+ )
+ const reservedOutputParameters = useMemo(
+ () => getReservedWorkflowOutputParameters(t),
+ [t],
+ )
const handleParameterChange = (key: string, value: string, index: number) => {
const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => {
@@ -105,18 +97,6 @@ const WorkflowToolAsModal: FC = ({
const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy)
const [showModal, setShowModal] = useState(false)
- const isNameValid = (name: string) => {
- // when the user has not input anything, no need for a warning
- if (name === '')
- return true
-
- return /^\w+$/.test(name)
- }
-
- const isOutputParameterReserved = (name: string) => {
- return reservedOutputParameters.find(p => p.name === name)
- }
-
const onConfirm = () => {
let errorMessage = ''
if (!label)
@@ -125,7 +105,7 @@ const WorkflowToolAsModal: FC = ({
if (!name)
errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) })
- if (!isNameValid(name))
+ if (!isWorkflowToolNameValid(name))
errorMessage = t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' })
if (errorMessage) {
@@ -133,19 +113,15 @@ const WorkflowToolAsModal: FC = ({
return
}
- const requestParams = {
+ const requestParams = buildWorkflowToolRequestPayload({
name,
description,
- icon: emoji,
+ emoji,
label,
- parameters: parameters.map(item => ({
- name: item.name,
- description: item.description,
- form: item.form,
- })),
+ parameters,
labels,
- privacy_policy: privacyPolicy,
- }
+ privacyPolicy,
+ })
if (!isAdd) {
onSave?.({
...requestParams,
@@ -175,7 +151,7 @@ const WorkflowToolAsModal: FC = ({
{/* name & icon */}
-
+
{t('createTool.name', { ns: 'tools' })}
{' '}
*
@@ -192,7 +168,7 @@ const WorkflowToolAsModal: FC
= ({
{/* name for tool call */}
-
+
{t('createTool.nameForToolCall', { ns: 'tools' })}
{' '}
*
@@ -210,13 +186,13 @@ const WorkflowToolAsModal: FC
= ({
value={name}
onChange={e => setName(e.target.value)}
/>
- {!isNameValid(name) && (
+ {!isWorkflowToolNameValid(name) && (
{t('createTool.nameForToolCallTip', { ns: 'tools' })}
)}
{/* description */}
-
{t('createTool.description', { ns: 'tools' })}
+
{t('createTool.description', { ns: 'tools' })}
{/* Tool Input */}
-
{t('createTool.toolInput.title', { ns: 'tools' })}
+
{t('createTool.toolInput.title', { ns: 'tools' })}
-
-
+
+
| {t('createTool.toolInput.name', { ns: 'tools' })} |
{t('createTool.toolInput.method', { ns: 'tools' })} |
@@ -265,7 +241,7 @@ const WorkflowToolAsModal: FC = ({
handleParameterChange('description', e.target.value, index)}
@@ -279,10 +255,10 @@ const WorkflowToolAsModal: FC = ({
{/* Tool Output */}
- {t('createTool.toolOutput.title', { ns: 'tools' })}
+ {t('createTool.toolOutput.title', { ns: 'tools' })}
-
-
+
+
| {t('createTool.name', { ns: 'tools' })} |
{t('createTool.toolOutput.description', { ns: 'tools' })} |
@@ -297,7 +273,7 @@ const WorkflowToolAsModal: FC = ({
{item.name}
{item.reserved ? t('createTool.toolOutput.reserved', { ns: 'tools' }) : ''}
{
- !item.reserved && isOutputParameterReserved(item.name)
+ !item.reserved && hasReservedWorkflowOutputConflict(reservedOutputParameters, item.name)
? (
= ({
- {item.description}
+ {item.description}
|
))}
@@ -326,12 +302,12 @@ const WorkflowToolAsModal: FC = ({
{/* Tags */}
- {t('createTool.toolInput.label', { ns: 'tools' })}
+ {t('createTool.toolInput.label', { ns: 'tools' })}
{/* Privacy Policy */}
- {t('createTool.privacyPolicy', { ns: 'tools' })}
+ {t('createTool.privacyPolicy', { ns: 'tools' })}
= ({
{!isAdd && onRemove && (
)}
-
+
|