& WorkflowFlowOptions
+type WorkflowFlowHookTestOptions = Omit, 'wrapper'> & WorkflowFlowOptions
+
+function createWorkflowFlowWrapper(
+ stores: StoreInstances,
+ {
+ historyStore: historyConfig,
+ nodes = [],
+ edges = [],
+ reactFlowProps,
+ canvasStyle,
+ }: WorkflowFlowOptions,
+) {
+ const workflowWrapper = createWorkflowWrapper(stores, historyConfig)
+
+ return ({ children }: { children: React.ReactNode }) => React.createElement(
+ workflowWrapper,
+ null,
+ React.createElement(
+ 'div',
+ { style: { width: 800, height: 600, ...canvasStyle } },
+ React.createElement(
+ ReactFlowProvider,
+ null,
+ React.createElement(ReactFlow, { fitView: true, ...reactFlowProps, nodes, edges }),
+ children,
+ ),
+ ),
+ )
+}
+
+export function renderWorkflowFlowComponent(
+ ui: React.ReactElement,
+ options?: WorkflowFlowComponentTestOptions,
+): WorkflowComponentTestResult {
+ const {
+ initialStoreState,
+ hooksStoreProps,
+ historyStore,
+ nodes,
+ edges,
+ reactFlowProps,
+ canvasStyle,
+ ...renderOptions
+ } = options ?? {}
+
+ const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
+ const wrapper = createWorkflowFlowWrapper(stores, {
+ historyStore,
+ nodes,
+ edges,
+ reactFlowProps,
+ canvasStyle,
+ })
+
+ const renderResult = render(ui, { wrapper, ...renderOptions })
+ return { ...renderResult, ...stores }
+}
+
+export function renderWorkflowFlowHook(
+ hook: (props: P) => R,
+ options?: WorkflowFlowHookTestOptions,
+): WorkflowHookTestResult {
+ const {
+ initialStoreState,
+ hooksStoreProps,
+ historyStore,
+ nodes,
+ edges,
+ reactFlowProps,
+ canvasStyle,
+ ...rest
+ } = options ?? {}
+
+ const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
+ const wrapper = createWorkflowFlowWrapper(stores, {
+ historyStore,
+ nodes,
+ edges,
+ reactFlowProps,
+ canvasStyle,
+ })
+
+ const renderResult = renderHook(hook, { wrapper, ...rest })
+ return { ...renderResult, ...stores }
+}
+
// ---------------------------------------------------------------------------
// renderNodeComponent — convenience wrapper for node components
// ---------------------------------------------------------------------------
diff --git a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx
new file mode 100644
index 0000000000..2b28662b45
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx
@@ -0,0 +1,277 @@
+import type { TriggerWithProvider } from '../types'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
+import { CollectionType } from '@/app/components/tools/types'
+import { useGlobalPublicStore } from '@/context/global-public-context'
+import { useGetLanguage, useLocale } from '@/context/i18n'
+import useTheme from '@/hooks/use-theme'
+import { useFeaturedTriggersRecommendations } from '@/service/use-plugins'
+import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
+import { Theme } from '@/types/app'
+import { defaultSystemFeatures } from '@/types/feature'
+import { useAvailableNodesMetaData } from '../../../workflow-app/hooks'
+import useNodes from '../../store/workflow/use-nodes'
+import { BlockEnum } from '../../types'
+import AllStartBlocks from '../all-start-blocks'
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: vi.fn(),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useGetLanguage: vi.fn(),
+ useLocale: vi.fn(),
+}))
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: vi.fn(),
+}))
+
+vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
+ useMarketplacePlugins: vi.fn(),
+}))
+
+vi.mock('@/service/use-triggers', () => ({
+ useAllTriggerPlugins: vi.fn(),
+ useInvalidateAllTriggerPlugins: vi.fn(),
+}))
+
+vi.mock('@/service/use-plugins', () => ({
+ useFeaturedTriggersRecommendations: vi.fn(),
+}))
+
+vi.mock('../../store/workflow/use-nodes', () => ({
+ default: vi.fn(),
+}))
+
+vi.mock('../../../workflow-app/hooks', () => ({
+ useAvailableNodesMetaData: vi.fn(),
+}))
+
+vi.mock('@/utils/var', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getMarketplaceUrl: () => 'https://marketplace.test/start',
+ }
+})
+
+const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
+const mockUseGetLanguage = vi.mocked(useGetLanguage)
+const mockUseLocale = vi.mocked(useLocale)
+const mockUseTheme = vi.mocked(useTheme)
+const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
+const mockUseAllTriggerPlugins = vi.mocked(useAllTriggerPlugins)
+const mockUseInvalidateAllTriggerPlugins = vi.mocked(useInvalidateAllTriggerPlugins)
+const mockUseFeaturedTriggersRecommendations = vi.mocked(useFeaturedTriggersRecommendations)
+const mockUseNodes = vi.mocked(useNodes)
+const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData)
+
+type UseMarketplacePluginsReturn = ReturnType
+type UseAllTriggerPluginsReturn = ReturnType
+type UseFeaturedTriggersRecommendationsReturn = ReturnType
+
+const createTriggerProvider = (overrides: Partial = {}): TriggerWithProvider => ({
+ id: 'provider-1',
+ name: 'provider-one',
+ author: 'Provider Author',
+ description: { en_US: 'desc', zh_Hans: '描述' },
+ icon: 'icon',
+ icon_dark: 'icon-dark',
+ label: { en_US: 'Provider One', zh_Hans: '提供商一' },
+ type: CollectionType.trigger,
+ team_credentials: {},
+ is_team_authorization: false,
+ allow_delete: false,
+ labels: [],
+ plugin_id: 'plugin-1',
+ plugin_unique_identifier: 'plugin-1@1.0.0',
+ meta: { version: '1.0.0' },
+ credentials_schema: [],
+ subscription_constructor: null,
+ subscription_schema: [],
+ supported_creation_methods: [],
+ events: [
+ {
+ name: 'created',
+ author: 'Provider Author',
+ label: { en_US: 'Created', zh_Hans: '创建' },
+ description: { en_US: 'Created event', zh_Hans: '创建事件' },
+ parameters: [],
+ labels: [],
+ output_schema: {},
+ },
+ ],
+ ...overrides,
+})
+
+const createSystemFeatures = (enableMarketplace: boolean) => ({
+ ...defaultSystemFeatures,
+ enable_marketplace: enableMarketplace,
+})
+
+const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
+ systemFeatures: createSystemFeatures(enableMarketplace),
+ setSystemFeatures: vi.fn(),
+})
+
+const createMarketplacePluginsMock = (
+ overrides: Partial = {},
+): UseMarketplacePluginsReturn => ({
+ plugins: [],
+ total: 0,
+ resetPlugins: vi.fn(),
+ queryPlugins: vi.fn(),
+ queryPluginsWithDebounced: vi.fn(),
+ cancelQueryPluginsWithDebounced: vi.fn(),
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage: vi.fn(),
+ page: 0,
+ ...overrides,
+})
+
+const createTriggerPluginsQueryResult = (
+ data: TriggerWithProvider[],
+): UseAllTriggerPluginsReturn => ({
+ data,
+ error: null,
+ isError: false,
+ isPending: false,
+ isLoading: false,
+ isSuccess: true,
+ isFetching: false,
+ isRefetching: false,
+ isLoadingError: false,
+ isRefetchError: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isEnabled: true,
+ status: 'success',
+ fetchStatus: 'idle',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: 0,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isPlaceholderData: false,
+ isStale: false,
+ refetch: vi.fn(),
+ promise: Promise.resolve(data),
+} as UseAllTriggerPluginsReturn)
+
+const createFeaturedTriggersRecommendationsMock = (
+ overrides: Partial = {},
+): UseFeaturedTriggersRecommendationsReturn => ({
+ plugins: [],
+ isLoading: false,
+ ...overrides,
+})
+
+const createAvailableNodesMetaData = (): ReturnType => ({
+ nodes: [],
+} as unknown as ReturnType)
+
+describe('AllStartBlocks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
+ mockUseGetLanguage.mockReturnValue('en_US')
+ mockUseLocale.mockReturnValue('en_US')
+ mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType)
+ mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
+ mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([createTriggerProvider()]))
+ mockUseInvalidateAllTriggerPlugins.mockReturnValue(vi.fn())
+ mockUseFeaturedTriggersRecommendations.mockReturnValue(createFeaturedTriggersRecommendationsMock())
+ mockUseNodes.mockReturnValue([])
+ mockUseAvailableNodesMetaData.mockReturnValue(createAvailableNodesMetaData())
+ })
+
+ // The combined start tab should merge built-in blocks, trigger plugins, and marketplace states.
+ describe('Content Rendering', () => {
+ it('should render start blocks and trigger plugin actions', async () => {
+ const user = userEvent.setup()
+ const onSelect = vi.fn()
+
+ render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByText('workflow.tabs.allTriggers')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
+ expect(screen.getByText('Provider One')).toBeInTheDocument()
+
+ await user.click(screen.getByText('workflow.blocks.start'))
+ expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start)
+
+ await user.click(screen.getByText('Provider One'))
+ await user.click(screen.getByText('Created'))
+
+ expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({
+ provider_id: 'provider-one',
+ event_name: 'created',
+ }))
+ })
+
+ it('should show marketplace footer when marketplace is enabled without filters', async () => {
+ mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
+
+ render(
+ ,
+ )
+
+ expect(await screen.findByRole('link', { name: /plugin\.findMoreInMarketplace/ })).toHaveAttribute('href', 'https://marketplace.test/start')
+ })
+ })
+
+ // Empty filter states should surface the request-to-community fallback.
+ describe('Filtered Empty State', () => {
+ it('should query marketplace and show the no-results state when filters have no matches', async () => {
+ const queryPluginsWithDebounced = vi.fn()
+ mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
+ mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({
+ queryPluginsWithDebounced,
+ }))
+ mockUseAllTriggerPlugins.mockReturnValue(createTriggerPluginsQueryResult([]))
+
+ render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(queryPluginsWithDebounced).toHaveBeenCalledWith({
+ query: 'missing',
+ tags: ['webhook'],
+ category: 'trigger',
+ })
+ })
+
+ expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'workflow.tabs.requestToCommunity' })).toHaveAttribute(
+ 'href',
+ 'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml',
+ )
+ })
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx b/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx
new file mode 100644
index 0000000000..64bcd514c6
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx
@@ -0,0 +1,186 @@
+import type { ToolWithProvider } from '../../types'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
+import { PluginCategoryEnum } from '@/app/components/plugins/types'
+import { CollectionType } from '@/app/components/tools/types'
+import { useGlobalPublicStore } from '@/context/global-public-context'
+import { useGetLanguage } from '@/context/i18n'
+import useTheme from '@/hooks/use-theme'
+import { Theme } from '@/types/app'
+import { defaultSystemFeatures } from '@/types/feature'
+import { BlockEnum } from '../../types'
+import DataSources from '../data-sources'
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: vi.fn(),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useGetLanguage: vi.fn(),
+}))
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: vi.fn(),
+}))
+
+vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
+ useMarketplacePlugins: vi.fn(),
+}))
+
+const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
+const mockUseGetLanguage = vi.mocked(useGetLanguage)
+const mockUseTheme = vi.mocked(useTheme)
+const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
+
+type UseMarketplacePluginsReturn = ReturnType
+
+const createToolProvider = (overrides: Partial = {}): ToolWithProvider => ({
+ id: 'langgenius/file',
+ name: 'file',
+ author: 'Dify',
+ description: { en_US: 'desc', zh_Hans: '描述' },
+ icon: 'icon',
+ label: { en_US: 'File Source', zh_Hans: '文件源' },
+ type: CollectionType.datasource,
+ team_credentials: {},
+ is_team_authorization: false,
+ allow_delete: false,
+ labels: [],
+ plugin_id: 'langgenius/file',
+ meta: { version: '1.0.0' },
+ tools: [
+ {
+ name: 'local-file',
+ author: 'Dify',
+ label: { en_US: 'Local File', zh_Hans: '本地文件' },
+ description: { en_US: 'Load local files', zh_Hans: '加载本地文件' },
+ parameters: [],
+ labels: [],
+ output_schema: {},
+ },
+ ],
+ ...overrides,
+})
+
+const createSystemFeatures = (enableMarketplace: boolean) => ({
+ ...defaultSystemFeatures,
+ enable_marketplace: enableMarketplace,
+})
+
+const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
+ systemFeatures: createSystemFeatures(enableMarketplace),
+ setSystemFeatures: vi.fn(),
+})
+
+const createMarketplacePluginsMock = (
+ overrides: Partial = {},
+): UseMarketplacePluginsReturn => ({
+ plugins: [],
+ total: 0,
+ resetPlugins: vi.fn(),
+ queryPlugins: vi.fn(),
+ queryPluginsWithDebounced: vi.fn(),
+ cancelQueryPluginsWithDebounced: vi.fn(),
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage: vi.fn(),
+ page: 0,
+ ...overrides,
+})
+
+describe('DataSources', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
+ mockUseGetLanguage.mockReturnValue('en_US')
+ mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType)
+ mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
+ })
+
+ // Data source tools should filter by search and normalize the default value payload.
+ describe('Selection', () => {
+ it('should add default file extensions for the built-in local file data source', async () => {
+ const user = userEvent.setup()
+ const onSelect = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('File Source'))
+ await user.click(screen.getByText('Local File'))
+
+ expect(onSelect).toHaveBeenCalledWith(BlockEnum.DataSource, expect.objectContaining({
+ provider_name: 'file',
+ datasource_name: 'local-file',
+ datasource_label: 'Local File',
+ fileExtensions: expect.arrayContaining(['txt', 'pdf', 'md']),
+ }))
+ })
+
+ it('should filter providers by search text', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Searchable Source')).toBeInTheDocument()
+ expect(screen.queryByText('Other Source')).not.toBeInTheDocument()
+ })
+ })
+
+ // Marketplace search should only run when enabled and a search term is present.
+ describe('Marketplace Search', () => {
+ it('should query marketplace plugins for datasource search results', async () => {
+ const queryPluginsWithDebounced = vi.fn()
+ mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
+ mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({
+ queryPluginsWithDebounced,
+ }))
+
+ render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(queryPluginsWithDebounced).toHaveBeenCalledWith({
+ query: 'invoice',
+ category: PluginCategoryEnum.datasource,
+ })
+ })
+ })
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx b/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx
new file mode 100644
index 0000000000..5955665f5e
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/featured-triggers.spec.tsx
@@ -0,0 +1,197 @@
+import type { TriggerWithProvider } from '../types'
+import type { Plugin } from '@/app/components/plugins/types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { PluginCategoryEnum, SupportedCreationMethods } from '@/app/components/plugins/types'
+import { CollectionType } from '@/app/components/tools/types'
+import useTheme from '@/hooks/use-theme'
+import { Theme } from '@/types/app'
+import { BlockEnum } from '../../types'
+import FeaturedTriggers from '../featured-triggers'
+
+vi.mock('@/context/i18n', () => ({
+ useGetLanguage: () => 'en_US',
+}))
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: vi.fn(),
+}))
+
+vi.mock('@/app/components/workflow/block-selector/market-place-plugin/action', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/utils/var', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getMarketplaceUrl: () => 'https://marketplace.test/triggers',
+ }
+})
+
+const mockUseTheme = vi.mocked(useTheme)
+
+const createPlugin = (overrides: Partial = {}): Plugin => ({
+ type: 'trigger',
+ org: 'org',
+ author: 'author',
+ name: 'trigger-plugin',
+ plugin_id: 'plugin-1',
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_package_identifier: 'plugin-1@1.0.0',
+ icon: 'icon',
+ verified: true,
+ label: { en_US: 'Plugin One', zh_Hans: '插件一' },
+ brief: { en_US: 'Brief', zh_Hans: '简介' },
+ description: { en_US: 'Plugin description', zh_Hans: '插件描述' },
+ introduction: 'Intro',
+ repository: 'https://example.com',
+ category: PluginCategoryEnum.trigger,
+ install_count: 12,
+ endpoint: { settings: [] },
+ tags: [{ name: 'tag' }],
+ badges: [],
+ verification: { authorized_category: 'community' },
+ from: 'marketplace',
+ ...overrides,
+})
+
+const createTriggerProvider = (overrides: Partial = {}): TriggerWithProvider => ({
+ id: 'provider-1',
+ name: 'provider-one',
+ author: 'Provider Author',
+ description: { en_US: 'desc', zh_Hans: '描述' },
+ icon: 'icon',
+ icon_dark: 'icon-dark',
+ label: { en_US: 'Provider One', zh_Hans: '提供商一' },
+ type: CollectionType.trigger,
+ team_credentials: {},
+ is_team_authorization: false,
+ allow_delete: false,
+ labels: [],
+ plugin_id: 'plugin-1',
+ plugin_unique_identifier: 'plugin-1@1.0.0',
+ meta: { version: '1.0.0' },
+ credentials_schema: [],
+ subscription_constructor: null,
+ subscription_schema: [],
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ events: [
+ {
+ name: 'created',
+ author: 'Provider Author',
+ label: { en_US: 'Created', zh_Hans: '创建' },
+ description: { en_US: 'Created event', zh_Hans: '创建事件' },
+ parameters: [],
+ labels: [],
+ output_schema: {},
+ },
+ ],
+ ...overrides,
+})
+
+describe('FeaturedTriggers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType)
+ })
+
+ // The section should persist collapse state and allow expanding recommended rows.
+ describe('Visibility Controls', () => {
+ it('should persist collapse state in localStorage', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: /workflow\.tabs\.featuredTools/ }))
+
+ expect(screen.queryByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).not.toBeInTheDocument()
+ expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('workflow_triggers_featured_collapsed', 'true')
+ })
+
+ it('should show more and show less across installed providers', async () => {
+ const user = userEvent.setup()
+ const providers = Array.from({ length: 6 }).map((_, index) => createTriggerProvider({
+ id: `provider-${index}`,
+ name: `provider-${index}`,
+ label: { en_US: `Provider ${index}`, zh_Hans: `提供商${index}` },
+ plugin_id: `plugin-${index}`,
+ plugin_unique_identifier: `plugin-${index}@1.0.0`,
+ }))
+ const providerMap = new Map(providers.map(provider => [provider.plugin_id!, provider]))
+ const plugins = providers.map(provider => createPlugin({
+ plugin_id: provider.plugin_id!,
+ latest_package_identifier: provider.plugin_unique_identifier,
+ }))
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Provider 4')).toBeInTheDocument()
+ expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
+
+ await user.click(screen.getByText('workflow.tabs.showMoreFeatured'))
+ expect(screen.getByText('Provider 5')).toBeInTheDocument()
+
+ await user.click(screen.getByText('workflow.tabs.showLessFeatured'))
+ expect(screen.queryByText('Provider 5')).not.toBeInTheDocument()
+ })
+ })
+
+ // Rendering should cover the empty state link and installed trigger selection.
+ describe('Rendering and Selection', () => {
+ it('should render the empty state link when there are no featured plugins', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('link', { name: 'workflow.tabs.noFeaturedTriggers' })).toHaveAttribute('href', 'https://marketplace.test/triggers')
+ })
+
+ it('should select an installed trigger event from the featured list', async () => {
+ const user = userEvent.setup()
+ const onSelect = vi.fn()
+ const provider = createTriggerProvider()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('Provider One'))
+ await user.click(screen.getByText('Created'))
+
+ expect(onSelect).toHaveBeenCalledWith(BlockEnum.TriggerPlugin, expect.objectContaining({
+ provider_id: 'provider-one',
+ event_name: 'created',
+ event_label: 'Created',
+ }))
+ })
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx b/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx
new file mode 100644
index 0000000000..91b158344b
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/index-bar.spec.tsx
@@ -0,0 +1,97 @@
+import type { ToolWithProvider } from '../../types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { CollectionType } from '../../../tools/types'
+import IndexBar, {
+ CUSTOM_GROUP_NAME,
+ DATA_SOURCE_GROUP_NAME,
+ groupItems,
+ WORKFLOW_GROUP_NAME,
+} from '../index-bar'
+
+const createToolProvider = (overrides: Partial = {}): ToolWithProvider => ({
+ id: 'provider-1',
+ name: 'Provider 1',
+ author: 'Author',
+ description: { en_US: 'desc', zh_Hans: '描述' },
+ icon: 'icon',
+ label: { en_US: 'Alpha', zh_Hans: '甲' },
+ type: CollectionType.builtIn,
+ team_credentials: {},
+ is_team_authorization: false,
+ allow_delete: false,
+ labels: [],
+ tools: [],
+ meta: { version: '1.0.0' },
+ ...overrides,
+})
+
+describe('IndexBar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Grouping should normalize Chinese initials, custom groups, and hash ordering.
+ describe('groupItems', () => {
+ it('should group providers by first letter and move hash to the end', () => {
+ const items: ToolWithProvider[] = [
+ createToolProvider({
+ id: 'alpha',
+ label: { en_US: 'Alpha', zh_Hans: '甲' },
+ type: CollectionType.builtIn,
+ author: 'Builtin',
+ }),
+ createToolProvider({
+ id: 'custom',
+ label: { en_US: '1Custom', zh_Hans: '1自定义' },
+ type: CollectionType.custom,
+ author: 'Custom',
+ }),
+ createToolProvider({
+ id: 'workflow',
+ label: { en_US: '中文工作流', zh_Hans: '中文工作流' },
+ type: CollectionType.workflow,
+ author: 'Workflow',
+ }),
+ createToolProvider({
+ id: 'source',
+ label: { en_US: 'Data Source', zh_Hans: '数据源' },
+ type: CollectionType.datasource,
+ author: 'Data',
+ }),
+ ]
+
+ const result = groupItems(items, item => item.label.zh_Hans[0] || item.label.en_US[0] || '')
+
+ expect(result.letters).toEqual(['J', 'S', 'Z', '#'])
+ expect(result.groups.J.Builtin).toHaveLength(1)
+ expect(result.groups.Z[WORKFLOW_GROUP_NAME]).toHaveLength(1)
+ expect(result.groups.S[DATA_SOURCE_GROUP_NAME]).toHaveLength(1)
+ expect(result.groups['#'][CUSTOM_GROUP_NAME]).toHaveLength(1)
+ })
+ })
+
+ // Clicking a letter should scroll the matching section into view.
+ describe('Rendering', () => {
+ it('should call scrollIntoView for the selected letter', async () => {
+ const user = userEvent.setup()
+ const scrollIntoView = vi.fn()
+ const itemRefs = {
+ current: {
+ A: { scrollIntoView } as unknown as HTMLElement,
+ },
+ }
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('A'))
+
+ expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
+ })
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx
new file mode 100644
index 0000000000..6bb50aeca3
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx
@@ -0,0 +1,80 @@
+import type { CommonNodeType } from '../../types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
+import { useAvailableNodesMetaData } from '../../../workflow-app/hooks'
+import { BlockEnum } from '../../types'
+import StartBlocks from '../start-blocks'
+
+vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
+ default: vi.fn(),
+}))
+
+vi.mock('../../../workflow-app/hooks', () => ({
+ useAvailableNodesMetaData: vi.fn(),
+}))
+
+const mockUseNodes = vi.mocked(useNodes)
+const mockUseAvailableNodesMetaData = vi.mocked(useAvailableNodesMetaData)
+
+const createNode = (type: BlockEnum) => ({
+ data: { type } as Pick,
+}) as ReturnType[number]
+
+const createAvailableNodesMetaData = (): ReturnType => ({
+ nodes: [],
+} as unknown as ReturnType)
+
+describe('StartBlocks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseNodes.mockReturnValue([])
+ mockUseAvailableNodesMetaData.mockReturnValue(createAvailableNodesMetaData())
+ })
+
+ // Start block selection should respect available types and workflow state.
+ describe('Filtering and Selection', () => {
+ it('should render available start blocks and forward selection', async () => {
+ const user = userEvent.setup()
+ const onSelect = vi.fn()
+ const onContentStateChange = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
+ expect(screen.getByText('workflow.blocks.trigger-webhook')).toBeInTheDocument()
+ expect(screen.getByText('workflow.blocks.originalStartNode')).toBeInTheDocument()
+ expect(onContentStateChange).toHaveBeenCalledWith(true)
+
+ await user.click(screen.getByText('workflow.blocks.start'))
+
+ expect(onSelect).toHaveBeenCalledWith(BlockEnum.Start)
+ })
+
+ it('should hide user input when a start node already exists or hideUserInput is enabled', () => {
+ const onContentStateChange = vi.fn()
+ mockUseNodes.mockReturnValue([createNode(BlockEnum.Start)])
+
+ const { container } = render(
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+ expect(screen.queryByText('workflow.blocks.start')).not.toBeInTheDocument()
+ expect(onContentStateChange).toHaveBeenCalledWith(false)
+ })
+ })
+})
diff --git a/web/app/components/workflow/edge-contextmenu.spec.tsx b/web/app/components/workflow/edge-contextmenu.spec.tsx
deleted file mode 100644
index c1b021e624..0000000000
--- a/web/app/components/workflow/edge-contextmenu.spec.tsx
+++ /dev/null
@@ -1,340 +0,0 @@
-import { fireEvent, screen, waitFor } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { useEffect } from 'react'
-import { resetReactFlowMockState, rfState } from './__tests__/reactflow-mock-state'
-import { renderWorkflowComponent } from './__tests__/workflow-test-env'
-import EdgeContextmenu from './edge-contextmenu'
-import { useEdgesInteractions } from './hooks/use-edges-interactions'
-
-vi.mock('reactflow', async () =>
- (await import('./__tests__/reactflow-mock-state')).createReactFlowModuleMock())
-
-const mockSaveStateToHistory = vi.fn()
-
-vi.mock('./hooks/use-workflow-history', () => ({
- useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }),
- WorkflowHistoryEvent: {
- EdgeDelete: 'EdgeDelete',
- EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
- EdgeSourceHandleChange: 'EdgeSourceHandleChange',
- },
-}))
-
-vi.mock('./hooks/use-workflow', () => ({
- useNodesReadOnly: () => ({
- getNodesReadOnly: () => false,
- }),
-}))
-
-vi.mock('./utils', async (importOriginal) => {
- const actual = await importOriginal()
-
- return {
- ...actual,
- getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
- }
-})
-
-vi.mock('./hooks', async () => {
- const { useEdgesInteractions } = await import('./hooks/use-edges-interactions')
- const { usePanelInteractions } = await import('./hooks/use-panel-interactions')
-
- return {
- useEdgesInteractions,
- usePanelInteractions,
- }
-})
-
-describe('EdgeContextmenu', () => {
- const hooksStoreProps = {
- doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
- }
- type TestNode = typeof rfState.nodes[number] & {
- selected?: boolean
- data: {
- selected?: boolean
- _isBundled?: boolean
- }
- }
- type TestEdge = typeof rfState.edges[number] & {
- selected?: boolean
- }
- const createNode = (id: string, selected = false): TestNode => ({
- id,
- position: { x: 0, y: 0 },
- data: { selected },
- selected,
- })
- const createEdge = (id: string, selected = false): TestEdge => ({
- id,
- source: 'n1',
- target: 'n2',
- data: {},
- selected,
- })
-
- const EdgeMenuHarness = () => {
- const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions()
-
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key !== 'Delete' && e.key !== 'Backspace')
- return
-
- e.preventDefault()
- handleEdgeDelete()
- }
-
- document.addEventListener('keydown', handleKeyDown)
- return () => {
- document.removeEventListener('keydown', handleKeyDown)
- }
- }, [handleEdgeDelete])
-
- return (
-
-
-
-
-
- )
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
- resetReactFlowMockState()
- rfState.nodes = [
- createNode('n1'),
- createNode('n2'),
- ]
- rfState.edges = [
- createEdge('e1', true) as typeof rfState.edges[number] & { selected: boolean },
- createEdge('e2'),
- ]
- rfState.setNodes.mockImplementation((nextNodes) => {
- rfState.nodes = nextNodes as typeof rfState.nodes
- })
- rfState.setEdges.mockImplementation((nextEdges) => {
- rfState.edges = nextEdges as typeof rfState.edges
- })
- })
-
- it('should not render when edgeMenu is absent', () => {
- renderWorkflowComponent(, {
- hooksStoreProps,
- })
-
- expect(screen.queryByRole('menu')).not.toBeInTheDocument()
- })
-
- it('should delete the menu edge and close the menu when another edge is selected', async () => {
- const user = userEvent.setup()
- ;(rfState.edges[0] as Record).selected = true
- ;(rfState.edges[1] as Record).selected = false
-
- const { store } = renderWorkflowComponent(, {
- initialStoreState: {
- edgeMenu: {
- clientX: 320,
- clientY: 180,
- edgeId: 'e2',
- },
- },
- hooksStoreProps,
- })
-
- const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
- expect(screen.getByText(/^del$/i)).toBeInTheDocument()
-
- await user.click(deleteAction)
-
- const updatedEdges = rfState.setEdges.mock.calls.at(-1)?.[0]
- expect(updatedEdges).toHaveLength(1)
- expect(updatedEdges[0].id).toBe('e1')
- expect(updatedEdges[0].selected).toBe(true)
- expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
-
- await waitFor(() => {
- expect(store.getState().edgeMenu).toBeUndefined()
- expect(screen.queryByRole('menu')).not.toBeInTheDocument()
- })
- })
-
- it('should not render the menu when the referenced edge no longer exists', () => {
- renderWorkflowComponent(, {
- initialStoreState: {
- edgeMenu: {
- clientX: 320,
- clientY: 180,
- edgeId: 'missing-edge',
- },
- },
- hooksStoreProps,
- })
-
- expect(screen.queryByRole('menu')).not.toBeInTheDocument()
- })
-
- it('should open the edge menu at the right-click position', async () => {
- const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
-
- renderWorkflowComponent(, {
- hooksStoreProps,
- })
-
- fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
- clientX: 320,
- clientY: 180,
- })
-
- expect(await screen.findByRole('menu')).toBeInTheDocument()
- expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument()
- expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
- x: 320,
- y: 180,
- width: 0,
- height: 0,
- }))
- })
-
- it('should delete the right-clicked edge and close the menu when delete is clicked', async () => {
- const user = userEvent.setup()
-
- renderWorkflowComponent(, {
- hooksStoreProps,
- })
-
- fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
- clientX: 320,
- clientY: 180,
- })
-
- await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i }))
-
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.toBeInTheDocument()
- })
- expect(rfState.edges.map(edge => edge.id)).toEqual(['e1'])
- expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
- })
-
- it.each([
- ['Delete', 'Delete'],
- ['Backspace', 'Backspace'],
- ])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => {
- renderWorkflowComponent(, {
- hooksStoreProps,
- })
- rfState.nodes = [createNode('n1', true), createNode('n2')]
-
- fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
- clientX: 240,
- clientY: 120,
- })
-
- expect(await screen.findByRole('menu')).toBeInTheDocument()
-
- fireEvent.keyDown(document, { key })
-
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.toBeInTheDocument()
- })
- expect(rfState.edges.map(edge => edge.id)).toEqual(['e1'])
- expect(rfState.nodes.map(node => node.id)).toEqual(['n1', 'n2'])
- expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected)).toBe(true)
- })
-
- it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => {
- renderWorkflowComponent(, {
- hooksStoreProps,
- })
- rfState.nodes = [
- { ...createNode('n1', true), data: { selected: true, _isBundled: true } },
- { ...createNode('n2', true), data: { selected: true, _isBundled: true } },
- ]
-
- fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
- clientX: 200,
- clientY: 100,
- })
-
- expect(await screen.findByRole('menu')).toBeInTheDocument()
-
- fireEvent.keyDown(document, { key: 'Delete' })
-
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.toBeInTheDocument()
- })
- expect(rfState.edges.map(edge => edge.id)).toEqual(['e2'])
- expect(rfState.nodes).toHaveLength(2)
- expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected && !node.data._isBundled)).toBe(true)
- })
-
- it('should retarget the menu and selected edge when right-clicking a different edge', async () => {
- const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
-
- renderWorkflowComponent(, {
- hooksStoreProps,
- })
- const edgeOneButton = screen.getByLabelText('Right-click edge e1')
- const edgeTwoButton = screen.getByLabelText('Right-click edge e2')
-
- fireEvent.contextMenu(edgeOneButton, {
- clientX: 80,
- clientY: 60,
- })
- expect(await screen.findByRole('menu')).toBeInTheDocument()
-
- fireEvent.contextMenu(edgeTwoButton, {
- clientX: 360,
- clientY: 240,
- })
-
- await waitFor(() => {
- expect(screen.getAllByRole('menu')).toHaveLength(1)
- expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
- x: 360,
- y: 240,
- }))
- expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e1')?.selected).toBe(false)
- expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e2')?.selected).toBe(true)
- })
- })
-
- it('should hide the menu when the target edge disappears after opening it', async () => {
- const { store } = renderWorkflowComponent(, {
- hooksStoreProps,
- })
-
- fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
- clientX: 160,
- clientY: 100,
- })
- expect(await screen.findByRole('menu')).toBeInTheDocument()
-
- rfState.edges = [createEdge('e2')]
- store.setState({
- edgeMenu: {
- clientX: 160,
- clientY: 100,
- edgeId: 'e1',
- },
- })
-
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.toBeInTheDocument()
- })
- })
-})
diff --git a/web/app/components/workflow/header/run-mode.spec.tsx b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx
similarity index 94%
rename from web/app/components/workflow/header/run-mode.spec.tsx
rename to web/app/components/workflow/header/__tests__/run-mode.spec.tsx
index 2f44d4a21b..cb5214544a 100644
--- a/web/app/components/workflow/header/run-mode.spec.tsx
+++ b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx
@@ -2,8 +2,8 @@ import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
-import RunMode from './run-mode'
-import { TriggerType } from './test-run-menu'
+import RunMode from '../run-mode'
+import { TriggerType } from '../test-run-menu'
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn()
@@ -42,7 +42,7 @@ vi.mock('@/app/components/workflow/store', () => ({
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
}))
-vi.mock('../hooks/use-dynamic-test-run-options', () => ({
+vi.mock('../../hooks/use-dynamic-test-run-options', () => ({
useDynamicTestRunOptions: () => mockDynamicOptions,
}))
@@ -72,8 +72,8 @@ vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
StopCircle: () => ,
}))
-vi.mock('./test-run-menu', async (importOriginal) => {
- const actual = await importOriginal()
+vi.mock('../test-run-menu', async (importOriginal) => {
+ const actual = await importOriginal()
return {
...actual,
default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => {
diff --git a/web/app/components/workflow/header/checklist/index.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx
similarity index 95%
rename from web/app/components/workflow/header/checklist/index.spec.tsx
rename to web/app/components/workflow/header/checklist/__tests__/index.spec.tsx
index 6a31bd6a74..2c83747dc0 100644
--- a/web/app/components/workflow/header/checklist/index.spec.tsx
+++ b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
-import { BlockEnum } from '../../types'
-import WorkflowChecklist from './index'
+import { BlockEnum } from '../../../types'
+import WorkflowChecklist from '../index'
let mockChecklistItems = [
{
@@ -40,7 +40,7 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: () => [],
}))
-vi.mock('../../hooks', () => ({
+vi.mock('../../../hooks', () => ({
useChecklist: () => mockChecklistItems,
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
@@ -57,11 +57,11 @@ vi.mock('@/app/components/base/ui/popover', () => ({
PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => ,
}))
-vi.mock('./plugin-group', () => ({
+vi.mock('../plugin-group', () => ({
ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => {items.map(item => item.title).join(',')}
,
}))
-vi.mock('./node-group', () => ({
+vi.mock('../node-group', () => ({
ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => (