& 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/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts b/web/app/components/workflow/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts
new file mode 100644
index 0000000000..a31d6035db
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts
@@ -0,0 +1,108 @@
+import type * as React from 'react'
+import { act, renderHook } from '@testing-library/react'
+import useCheckVerticalScrollbar from '../use-check-vertical-scrollbar'
+
+const resizeObserve = vi.fn()
+const resizeDisconnect = vi.fn()
+const mutationObserve = vi.fn()
+const mutationDisconnect = vi.fn()
+
+let resizeCallback: ResizeObserverCallback | null = null
+let mutationCallback: MutationCallback | null = null
+
+class MockResizeObserver implements ResizeObserver {
+ observe = resizeObserve
+ unobserve = vi.fn()
+ disconnect = resizeDisconnect
+
+ constructor(callback: ResizeObserverCallback) {
+ resizeCallback = callback
+ }
+}
+
+class MockMutationObserver implements MutationObserver {
+ observe = mutationObserve
+ disconnect = mutationDisconnect
+ takeRecords = vi.fn(() => [])
+
+ constructor(callback: MutationCallback) {
+ mutationCallback = callback
+ }
+}
+
+const setElementHeights = (element: HTMLElement, scrollHeight: number, clientHeight: number) => {
+ Object.defineProperty(element, 'scrollHeight', {
+ configurable: true,
+ value: scrollHeight,
+ })
+ Object.defineProperty(element, 'clientHeight', {
+ configurable: true,
+ value: clientHeight,
+ })
+}
+
+describe('useCheckVerticalScrollbar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resizeCallback = null
+ mutationCallback = null
+ vi.stubGlobal('ResizeObserver', MockResizeObserver)
+ vi.stubGlobal('MutationObserver', MockMutationObserver)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('should return false when the element ref is empty', () => {
+ const ref = { current: null } as React.RefObject
+
+ const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
+
+ expect(result.current).toBe(false)
+ expect(resizeObserve).not.toHaveBeenCalled()
+ expect(mutationObserve).not.toHaveBeenCalled()
+ })
+
+ it('should detect the initial scrollbar state and react to observer updates', () => {
+ const element = document.createElement('div')
+ setElementHeights(element, 200, 100)
+ const ref = { current: element } as React.RefObject
+
+ const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
+
+ expect(result.current).toBe(true)
+ expect(resizeObserve).toHaveBeenCalledWith(element)
+ expect(mutationObserve).toHaveBeenCalledWith(element, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ })
+
+ setElementHeights(element, 100, 100)
+ act(() => {
+ resizeCallback?.([] as ResizeObserverEntry[], new MockResizeObserver(() => {}))
+ })
+
+ expect(result.current).toBe(false)
+
+ setElementHeights(element, 180, 100)
+ act(() => {
+ mutationCallback?.([] as MutationRecord[], new MockMutationObserver(() => {}))
+ })
+
+ expect(result.current).toBe(true)
+ })
+
+ it('should disconnect observers on unmount', () => {
+ const element = document.createElement('div')
+ setElementHeights(element, 120, 100)
+ const ref = { current: element } as React.RefObject
+
+ const { unmount } = renderHook(() => useCheckVerticalScrollbar(ref))
+ unmount()
+
+ expect(resizeDisconnect).toHaveBeenCalledTimes(1)
+ expect(mutationDisconnect).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts b/web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts
new file mode 100644
index 0000000000..5949a74682
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts
@@ -0,0 +1,103 @@
+import type * as React from 'react'
+import { act, renderHook } from '@testing-library/react'
+import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll'
+
+const setRect = (element: HTMLElement, top: number, height: number) => {
+ element.getBoundingClientRect = vi.fn(() => new DOMRect(0, top, 100, height))
+}
+
+describe('useStickyScroll', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ const runScroll = (handleScroll: () => void) => {
+ act(() => {
+ handleScroll()
+ vi.advanceTimersByTime(120)
+ })
+ }
+
+ it('should keep the default state when refs are missing', () => {
+ const wrapElemRef = { current: null } as React.RefObject
+ const nextToStickyELemRef = { current: null } as React.RefObject
+
+ const { result } = renderHook(() =>
+ useStickyScroll({
+ wrapElemRef,
+ nextToStickyELemRef,
+ }),
+ )
+
+ runScroll(result.current.handleScroll)
+
+ expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
+ })
+
+ it('should mark the sticky element as below the wrapper when it is outside the visible area', () => {
+ const wrapElement = document.createElement('div')
+ const nextElement = document.createElement('div')
+ setRect(wrapElement, 100, 200)
+ setRect(nextElement, 320, 20)
+
+ const wrapElemRef = { current: wrapElement } as React.RefObject
+ const nextToStickyELemRef = { current: nextElement } as React.RefObject
+
+ const { result } = renderHook(() =>
+ useStickyScroll({
+ wrapElemRef,
+ nextToStickyELemRef,
+ }),
+ )
+
+ runScroll(result.current.handleScroll)
+
+ expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
+ })
+
+ it('should mark the sticky element as showing when it is within the wrapper', () => {
+ const wrapElement = document.createElement('div')
+ const nextElement = document.createElement('div')
+ setRect(wrapElement, 100, 200)
+ setRect(nextElement, 220, 20)
+
+ const wrapElemRef = { current: wrapElement } as React.RefObject
+ const nextToStickyELemRef = { current: nextElement } as React.RefObject
+
+ const { result } = renderHook(() =>
+ useStickyScroll({
+ wrapElemRef,
+ nextToStickyELemRef,
+ }),
+ )
+
+ runScroll(result.current.handleScroll)
+
+ expect(result.current.scrollPosition).toBe(ScrollPosition.showing)
+ })
+
+ it('should mark the sticky element as above the wrapper when it has scrolled past the top', () => {
+ const wrapElement = document.createElement('div')
+ const nextElement = document.createElement('div')
+ setRect(wrapElement, 100, 200)
+ setRect(nextElement, 90, 20)
+
+ const wrapElemRef = { current: wrapElement } as React.RefObject
+ const nextToStickyELemRef = { current: nextElement } as React.RefObject
+
+ const { result } = renderHook(() =>
+ useStickyScroll({
+ wrapElemRef,
+ nextToStickyELemRef,
+ }),
+ )
+
+ runScroll(result.current.handleScroll)
+
+ expect(result.current.scrollPosition).toBe(ScrollPosition.aboveTheWrap)
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/utils.spec.ts b/web/app/components/workflow/block-selector/__tests__/utils.spec.ts
new file mode 100644
index 0000000000..b003ef7561
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/utils.spec.ts
@@ -0,0 +1,108 @@
+import type { DataSourceItem } from '../types'
+import { transformDataSourceToTool } from '../utils'
+
+const createLocalizedText = (text: string) => ({
+ en_US: text,
+ zh_Hans: text,
+})
+
+const createDataSourceItem = (overrides: Partial = {}): DataSourceItem => ({
+ plugin_id: 'plugin-1',
+ plugin_unique_identifier: 'plugin-1@provider',
+ provider: 'provider-a',
+ declaration: {
+ credentials_schema: [{ name: 'api_key' }],
+ provider_type: 'hosted',
+ identity: {
+ author: 'Dify',
+ description: createLocalizedText('Datasource provider'),
+ icon: 'provider-icon',
+ label: createLocalizedText('Provider A'),
+ name: 'provider-a',
+ tags: ['retrieval', 'storage'],
+ },
+ datasources: [
+ {
+ description: createLocalizedText('Search in documents'),
+ identity: {
+ author: 'Dify',
+ label: createLocalizedText('Document Search'),
+ name: 'document_search',
+ provider: 'provider-a',
+ },
+ parameters: [{ name: 'query', type: 'string' }],
+ output_schema: {
+ type: 'object',
+ properties: {
+ result: { type: 'string' },
+ },
+ },
+ },
+ ],
+ },
+ is_authorized: true,
+ ...overrides,
+})
+
+describe('transformDataSourceToTool', () => {
+ it('should map datasource provider fields to tool shape', () => {
+ const dataSourceItem = createDataSourceItem()
+
+ const result = transformDataSourceToTool(dataSourceItem)
+
+ expect(result).toMatchObject({
+ id: 'plugin-1',
+ provider: 'provider-a',
+ name: 'provider-a',
+ author: 'Dify',
+ description: createLocalizedText('Datasource provider'),
+ icon: 'provider-icon',
+ label: createLocalizedText('Provider A'),
+ type: 'hosted',
+ allow_delete: true,
+ is_authorized: true,
+ is_team_authorization: true,
+ labels: ['retrieval', 'storage'],
+ plugin_id: 'plugin-1',
+ plugin_unique_identifier: 'plugin-1@provider',
+ credentialsSchema: [{ name: 'api_key' }],
+ meta: { version: '' },
+ })
+ expect(result.team_credentials).toEqual({})
+ expect(result.tools).toEqual([
+ {
+ name: 'document_search',
+ author: 'Dify',
+ label: createLocalizedText('Document Search'),
+ description: createLocalizedText('Search in documents'),
+ parameters: [{ name: 'query', type: 'string' }],
+ labels: [],
+ output_schema: {
+ type: 'object',
+ properties: {
+ result: { type: 'string' },
+ },
+ },
+ },
+ ])
+ })
+
+ it('should fallback to empty arrays when tags and credentials schema are missing', () => {
+ const baseDataSourceItem = createDataSourceItem()
+ const dataSourceItem = createDataSourceItem({
+ declaration: {
+ ...baseDataSourceItem.declaration,
+ credentials_schema: undefined as unknown as DataSourceItem['declaration']['credentials_schema'],
+ identity: {
+ ...baseDataSourceItem.declaration.identity,
+ tags: undefined as unknown as DataSourceItem['declaration']['identity']['tags'],
+ },
+ },
+ })
+
+ const result = transformDataSourceToTool(dataSourceItem)
+
+ expect(result.labels).toEqual([])
+ expect(result.credentialsSchema).toEqual([])
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx
new file mode 100644
index 0000000000..40e5bacd83
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx
@@ -0,0 +1,57 @@
+import { fireEvent, render } from '@testing-library/react'
+import ViewTypeSelect, { ViewType } from '../view-type-select'
+
+const getViewOptions = (container: HTMLElement) => {
+ const options = container.firstElementChild?.children
+ if (!options || options.length !== 2)
+ throw new Error('Expected two view options')
+ return [options[0] as HTMLDivElement, options[1] as HTMLDivElement]
+}
+
+describe('ViewTypeSelect', () => {
+ it('should highlight the active view type', () => {
+ const onChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ const [flatOption, treeOption] = getViewOptions(container)
+
+ expect(flatOption).toHaveClass('bg-components-segmented-control-item-active-bg')
+ expect(treeOption).toHaveClass('cursor-pointer')
+ })
+
+ it('should call onChange when switching to a different view type', () => {
+ const onChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ const [, treeOption] = getViewOptions(container)
+ fireEvent.click(treeOption)
+
+ expect(onChange).toHaveBeenCalledWith(ViewType.tree)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+
+ it('should ignore clicks on the current view type', () => {
+ const onChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ const [, treeOption] = getViewOptions(container)
+ fireEvent.click(treeOption)
+
+ expect(onChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts
index e8f5fc0559..e5c1f208fb 100644
--- a/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts
+++ b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
-const useCheckVerticalScrollbar = (ref: React.RefObject) => {
+const useCheckVerticalScrollbar = (ref: React.RefObject) => {
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false)
useEffect(() => {
diff --git a/web/app/components/workflow/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/__tests__/chat-variable-button.spec.tsx b/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx
new file mode 100644
index 0000000000..ebe8321044
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx
@@ -0,0 +1,59 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import ChatVariableButton from '../chat-variable-button'
+
+let mockTheme: 'light' | 'dark' = 'light'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({
+ theme: mockTheme,
+ }),
+}))
+
+describe('ChatVariableButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'light'
+ })
+
+ it('opens the chat variable panel and closes the other workflow panels', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showEnvPanel: true,
+ showGlobalVariablePanel: true,
+ showDebugAndPreviewPanel: true,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(store.getState().showChatVariablePanel).toBe(true)
+ expect(store.getState().showEnvPanel).toBe(false)
+ expect(store.getState().showGlobalVariablePanel).toBe(false)
+ expect(store.getState().showDebugAndPreviewPanel).toBe(false)
+ })
+
+ it('applies the active dark theme styles when the chat variable panel is visible', () => {
+ mockTheme = 'dark'
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ showChatVariablePanel: true,
+ },
+ })
+
+ expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
+ })
+
+ it('stays disabled without mutating panel state', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showChatVariablePanel: false,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ expect(store.getState().showChatVariablePanel).toBe(false)
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/editing-title.spec.tsx b/web/app/components/workflow/header/__tests__/editing-title.spec.tsx
new file mode 100644
index 0000000000..2dbb1b4b86
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/editing-title.spec.tsx
@@ -0,0 +1,63 @@
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import EditingTitle from '../editing-title'
+
+const mockFormatTime = vi.fn()
+const mockFormatTimeFromNow = vi.fn()
+
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: mockFormatTimeFromNow,
+ }),
+}))
+
+describe('EditingTitle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFormatTime.mockReturnValue('08:00:00')
+ mockFormatTimeFromNow.mockReturnValue('2 hours ago')
+ })
+
+ it('should render autosave, published time, and syncing status when the draft has metadata', () => {
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ draftUpdatedAt: 1_710_000_000_000,
+ publishedAt: 1_710_003_600_000,
+ isSyncingWorkflowDraft: true,
+ maximizeCanvas: true,
+ },
+ })
+
+ expect(mockFormatTime).toHaveBeenCalledWith(1_710_000_000, 'HH:mm:ss')
+ expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1_710_003_600_000)
+ expect(container.firstChild).toHaveClass('ml-2')
+ expect(container).toHaveTextContent('workflow.common.autoSaved')
+ expect(container).toHaveTextContent('08:00:00')
+ expect(container).toHaveTextContent('workflow.common.published')
+ expect(container).toHaveTextContent('2 hours ago')
+ expect(container).toHaveTextContent('workflow.common.syncingData')
+ })
+
+ it('should render unpublished status without autosave metadata when the workflow has not been published', () => {
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ draftUpdatedAt: 0,
+ publishedAt: 0,
+ isSyncingWorkflowDraft: false,
+ maximizeCanvas: false,
+ },
+ })
+
+ expect(mockFormatTime).not.toHaveBeenCalled()
+ expect(mockFormatTimeFromNow).not.toHaveBeenCalled()
+ expect(container.firstChild).not.toHaveClass('ml-2')
+ expect(container).toHaveTextContent('workflow.common.unpublished')
+ expect(container).not.toHaveTextContent('workflow.common.autoSaved')
+ expect(container).not.toHaveTextContent('workflow.common.syncingData')
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/env-button.spec.tsx b/web/app/components/workflow/header/__tests__/env-button.spec.tsx
new file mode 100644
index 0000000000..268c54714e
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/env-button.spec.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import EnvButton from '../env-button'
+
+const mockCloseAllInputFieldPanels = vi.fn()
+let mockTheme: 'light' | 'dark' = 'light'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({
+ theme: mockTheme,
+ }),
+}))
+
+vi.mock('@/app/components/rag-pipeline/hooks', () => ({
+ useInputFieldPanel: () => ({
+ closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
+ }),
+}))
+
+describe('EnvButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'light'
+ })
+
+ it('should open the environment panel and close the other panels when clicked', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showChatVariablePanel: true,
+ showGlobalVariablePanel: true,
+ showDebugAndPreviewPanel: true,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(store.getState().showEnvPanel).toBe(true)
+ expect(store.getState().showChatVariablePanel).toBe(false)
+ expect(store.getState().showGlobalVariablePanel).toBe(false)
+ expect(store.getState().showDebugAndPreviewPanel).toBe(false)
+ expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
+ })
+
+ it('should apply the active dark theme styles when the environment panel is visible', () => {
+ mockTheme = 'dark'
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ showEnvPanel: true,
+ },
+ })
+
+ expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
+ })
+
+ it('should keep the button disabled when the disabled prop is true', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showEnvPanel: false,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ expect(store.getState().showEnvPanel).toBe(false)
+ expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx
new file mode 100644
index 0000000000..fe17f940b8
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import GlobalVariableButton from '../global-variable-button'
+
+const mockCloseAllInputFieldPanels = vi.fn()
+let mockTheme: 'light' | 'dark' = 'light'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({
+ theme: mockTheme,
+ }),
+}))
+
+vi.mock('@/app/components/rag-pipeline/hooks', () => ({
+ useInputFieldPanel: () => ({
+ closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
+ }),
+}))
+
+describe('GlobalVariableButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'light'
+ })
+
+ it('should open the global variable panel and close the other panels when clicked', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showEnvPanel: true,
+ showChatVariablePanel: true,
+ showDebugAndPreviewPanel: true,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(store.getState().showGlobalVariablePanel).toBe(true)
+ expect(store.getState().showEnvPanel).toBe(false)
+ expect(store.getState().showChatVariablePanel).toBe(false)
+ expect(store.getState().showDebugAndPreviewPanel).toBe(false)
+ expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
+ })
+
+ it('should apply the active dark theme styles when the global variable panel is visible', () => {
+ mockTheme = 'dark'
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ showGlobalVariablePanel: true,
+ },
+ })
+
+ expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
+ })
+
+ it('should keep the button disabled when the disabled prop is true', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showGlobalVariablePanel: false,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ expect(store.getState().showGlobalVariablePanel).toBe(false)
+ expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx b/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx
new file mode 100644
index 0000000000..f5d138af42
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx
@@ -0,0 +1,109 @@
+import type { VersionHistory } from '@/types/workflow'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import { WorkflowVersion } from '../../types'
+import RestoringTitle from '../restoring-title'
+
+const mockFormatTime = vi.fn()
+const mockFormatTimeFromNow = vi.fn()
+
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: mockFormatTimeFromNow,
+ }),
+}))
+
+const createVersion = (overrides: Partial = {}): VersionHistory => ({
+ id: 'version-1',
+ graph: {
+ nodes: [],
+ edges: [],
+ },
+ created_at: 1_700_000_000,
+ created_by: {
+ id: 'user-1',
+ name: 'Alice',
+ email: 'alice@example.com',
+ },
+ hash: 'hash-1',
+ updated_at: 1_700_000_100,
+ updated_by: {
+ id: 'user-2',
+ name: 'Bob',
+ email: 'bob@example.com',
+ },
+ tool_published: false,
+ version: 'v1',
+ marked_name: 'Release 1',
+ marked_comment: '',
+ ...overrides,
+})
+
+describe('RestoringTitle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFormatTime.mockReturnValue('09:30:00')
+ mockFormatTimeFromNow.mockReturnValue('3 hours ago')
+ })
+
+ it('should render draft metadata when the current version is a draft', () => {
+ const currentVersion = createVersion({
+ version: WorkflowVersion.Draft,
+ })
+
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ currentVersion,
+ },
+ })
+
+ expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.updated_at * 1000)
+ expect(mockFormatTime).toHaveBeenCalledWith(currentVersion.created_at, 'HH:mm:ss')
+ expect(container).toHaveTextContent('workflow.versionHistory.currentDraft')
+ expect(container).toHaveTextContent('workflow.common.viewOnly')
+ expect(container).toHaveTextContent('workflow.common.unpublished')
+ expect(container).toHaveTextContent('3 hours ago 09:30:00')
+ expect(container).toHaveTextContent('Alice')
+ })
+
+ it('should render published metadata and fallback version name when the marked name is empty', () => {
+ const currentVersion = createVersion({
+ marked_name: '',
+ })
+
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ currentVersion,
+ },
+ })
+
+ expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.created_at * 1000)
+ expect(container).toHaveTextContent('workflow.versionHistory.defaultName')
+ expect(container).toHaveTextContent('workflow.common.published')
+ expect(container).toHaveTextContent('Alice')
+ })
+
+ it('should render an empty creator name when the version creator name is missing', () => {
+ const currentVersion = createVersion({
+ created_by: {
+ id: 'user-1',
+ name: '',
+ email: 'alice@example.com',
+ },
+ })
+
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ currentVersion,
+ },
+ })
+
+ expect(container).toHaveTextContent('workflow.common.published')
+ expect(container).not.toHaveTextContent('Alice')
+ })
+})
diff --git a/web/app/components/workflow/header/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/__tests__/running-title.spec.tsx b/web/app/components/workflow/header/__tests__/running-title.spec.tsx
new file mode 100644
index 0000000000..7d904ed74a
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/running-title.spec.tsx
@@ -0,0 +1,61 @@
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import RunningTitle from '../running-title'
+
+let mockIsChatMode = false
+const mockFormatWorkflowRunIdentifier = vi.fn()
+
+vi.mock('../../hooks', () => ({
+ useIsChatMode: () => mockIsChatMode,
+}))
+
+vi.mock('../../utils', () => ({
+ formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
+}))
+
+describe('RunningTitle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsChatMode = false
+ mockFormatWorkflowRunIdentifier.mockReturnValue(' (14:30:25)')
+ })
+
+ it('should render the test run title in workflow mode', () => {
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'history-1',
+ status: 'succeeded',
+ finished_at: 1_700_000_000,
+ },
+ },
+ })
+
+ expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1_700_000_000)
+ expect(container).toHaveTextContent('Test Run (14:30:25)')
+ expect(container).toHaveTextContent('workflow.common.viewOnly')
+ })
+
+ it('should render the test chat title in chat mode', () => {
+ mockIsChatMode = true
+
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'history-2',
+ status: 'running',
+ finished_at: undefined,
+ },
+ },
+ })
+
+ expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
+ expect(container).toHaveTextContent('Test Chat (14:30:25)')
+ })
+
+ it('should handle missing workflow history data', () => {
+ const { container } = renderWorkflowComponent()
+
+ expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
+ expect(container).toHaveTextContent('Test Run (14:30:25)')
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx b/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx
new file mode 100644
index 0000000000..7fbc70db23
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx
@@ -0,0 +1,53 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { createNode } from '../../__tests__/fixtures'
+import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
+import ScrollToSelectedNodeButton from '../scroll-to-selected-node-button'
+
+const mockScrollToWorkflowNode = vi.fn()
+
+vi.mock('reactflow', async () =>
+ (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
+
+vi.mock('../../utils/node-navigation', () => ({
+ scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId),
+}))
+
+describe('ScrollToSelectedNodeButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetReactFlowMockState()
+ })
+
+ it('should render nothing when there is no selected node', () => {
+ rfState.nodes = [
+ createNode({
+ id: 'node-1',
+ data: { selected: false },
+ }),
+ ]
+
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render the action and scroll to the selected node when clicked', () => {
+ rfState.nodes = [
+ createNode({
+ id: 'node-1',
+ data: { selected: false },
+ }),
+ createNode({
+ id: 'node-2',
+ data: { selected: true },
+ }),
+ ]
+
+ render()
+
+ fireEvent.click(screen.getByText('workflow.panel.scrollToSelectedNode'))
+
+ expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('node-2')
+ expect(mockScrollToWorkflowNode).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx
new file mode 100644
index 0000000000..767de6a6a8
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx
@@ -0,0 +1,118 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import UndoRedo from '../undo-redo'
+
+type TemporalSnapshot = {
+ pastStates: unknown[]
+ futureStates: unknown[]
+}
+
+const mockUnsubscribe = vi.fn()
+const mockTemporalSubscribe = vi.fn()
+const mockHandleUndo = vi.fn()
+const mockHandleRedo = vi.fn()
+
+let latestTemporalListener: ((state: TemporalSnapshot) => void) | undefined
+let mockNodesReadOnly = false
+
+vi.mock('@/app/components/workflow/header/view-workflow-history', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesReadOnly: () => ({
+ nodesReadOnly: mockNodesReadOnly,
+ }),
+}))
+
+vi.mock('@/app/components/workflow/workflow-history-store', () => ({
+ useWorkflowHistoryStore: () => ({
+ store: {
+ temporal: {
+ subscribe: mockTemporalSubscribe,
+ },
+ },
+ shortcutsEnabled: true,
+ setShortcutsEnabled: vi.fn(),
+ }),
+}))
+
+vi.mock('@/app/components/base/divider', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/workflow/operator/tip-popup', () => ({
+ default: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+}))
+
+describe('UndoRedo', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockNodesReadOnly = false
+ latestTemporalListener = undefined
+ mockTemporalSubscribe.mockImplementation((listener: (state: TemporalSnapshot) => void) => {
+ latestTemporalListener = listener
+ return mockUnsubscribe
+ })
+ })
+
+ it('enables undo and redo when history exists and triggers the callbacks', () => {
+ render()
+
+ act(() => {
+ latestTemporalListener?.({
+ pastStates: [{}],
+ futureStates: [{}],
+ })
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.undo' }))
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.redo' }))
+
+ expect(mockHandleUndo).toHaveBeenCalledTimes(1)
+ expect(mockHandleRedo).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps the buttons disabled before history is available', () => {
+ render()
+ const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
+ const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
+
+ fireEvent.click(undoButton)
+ fireEvent.click(redoButton)
+
+ expect(undoButton).toBeDisabled()
+ expect(redoButton).toBeDisabled()
+ expect(mockHandleUndo).not.toHaveBeenCalled()
+ expect(mockHandleRedo).not.toHaveBeenCalled()
+ })
+
+ it('does not trigger callbacks when the canvas is read only', () => {
+ mockNodesReadOnly = true
+ render()
+ const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
+ const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
+
+ act(() => {
+ latestTemporalListener?.({
+ pastStates: [{}],
+ futureStates: [{}],
+ })
+ })
+
+ fireEvent.click(undoButton)
+ fireEvent.click(redoButton)
+
+ expect(undoButton).toBeDisabled()
+ expect(redoButton).toBeDisabled()
+ expect(mockHandleUndo).not.toHaveBeenCalled()
+ expect(mockHandleRedo).not.toHaveBeenCalled()
+ })
+
+ it('unsubscribes from the temporal store on unmount', () => {
+ const { unmount } = render()
+
+ unmount()
+
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx
new file mode 100644
index 0000000000..bc066adba5
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import VersionHistoryButton from '../version-history-button'
+
+let mockTheme: 'light' | 'dark' = 'light'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({
+ theme: mockTheme,
+ }),
+}))
+
+vi.mock('../../utils', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getKeyboardKeyCodeBySystem: () => 'ctrl',
+ }
+})
+
+describe('VersionHistoryButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'light'
+ })
+
+ it('should call onClick when the button is clicked', () => {
+ const onClick = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should trigger onClick when the version history shortcut is pressed', () => {
+ const onClick = vi.fn()
+ render()
+
+ const keyboardEvent = new KeyboardEvent('keydown', {
+ key: 'H',
+ ctrlKey: true,
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true,
+ })
+ Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 })
+ Object.defineProperty(keyboardEvent, 'which', { value: 72 })
+ window.dispatchEvent(keyboardEvent)
+
+ expect(keyboardEvent.defaultPrevented).toBe(true)
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render the tooltip popup content on hover', async () => {
+ render()
+
+ fireEvent.mouseEnter(screen.getByRole('button'))
+
+ expect(await screen.findByText('workflow.common.versionHistory')).toBeInTheDocument()
+ })
+
+ it('should apply dark theme styles when the theme is dark', () => {
+ mockTheme = 'dark'
+ render()
+
+ expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/view-history.spec.tsx b/web/app/components/workflow/header/__tests__/view-history.spec.tsx
new file mode 100644
index 0000000000..4481c72cf7
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/view-history.spec.tsx
@@ -0,0 +1,276 @@
+import type { WorkflowRunHistory, WorkflowRunHistoryResponse } from '@/types/workflow'
+import { fireEvent, screen } from '@testing-library/react'
+import * as React from 'react'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import { ControlMode, WorkflowRunningStatus } from '../../types'
+import ViewHistory from '../view-history'
+
+const mockUseWorkflowRunHistory = vi.fn()
+const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`)
+const mockCloseAllInputFieldPanels = vi.fn()
+const mockHandleNodesCancelSelected = vi.fn()
+const mockHandleCancelDebugAndPreviewPanel = vi.fn()
+const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
+
+let mockIsChatMode = false
+
+vi.mock('../../hooks', async () => {
+ const actual = await vi.importActual('../../hooks')
+ return {
+ ...actual,
+ useIsChatMode: () => mockIsChatMode,
+ useNodesInteractions: () => ({
+ handleNodesCancelSelected: mockHandleNodesCancelSelected,
+ }),
+ useWorkflowInteractions: () => ({
+ handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
+ }),
+ }
+})
+
+vi.mock('@/service/use-workflow', () => ({
+ useWorkflowRunHistory: (url?: string, enabled?: boolean) => mockUseWorkflowRunHistory(url, enabled),
+}))
+
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: mockFormatTimeFromNow,
+ }),
+}))
+
+vi.mock('@/app/components/rag-pipeline/hooks', () => ({
+ useInputFieldPanel: () => ({
+ closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
+ }),
+}))
+
+vi.mock('@/app/components/base/loading', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/base/tooltip', () => ({
+ default: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+}))
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => {
+ const PortalContext = React.createContext({ open: false })
+
+ return {
+ PortalToFollowElem: ({
+ children,
+ open,
+ }: {
+ children?: React.ReactNode
+ open: boolean
+ }) => {children},
+ PortalToFollowElemTrigger: ({
+ children,
+ onClick,
+ }: {
+ children?: React.ReactNode
+ onClick?: () => void
+ }) => {children}
,
+ PortalToFollowElemContent: ({
+ children,
+ }: {
+ children?: React.ReactNode
+ }) => {
+ const { open } = React.useContext(PortalContext)
+ return open ? {children}
: null
+ },
+ }
+})
+
+vi.mock('../../utils', async () => {
+ const actual = await vi.importActual('../../utils')
+ return {
+ ...actual,
+ formatWorkflowRunIdentifier: (finishedAt?: number, status?: string) => mockFormatWorkflowRunIdentifier(finishedAt, status),
+ }
+})
+
+const createHistoryItem = (overrides: Partial = {}): WorkflowRunHistory => ({
+ id: 'run-1',
+ version: 'v1',
+ graph: {
+ nodes: [],
+ edges: [],
+ },
+ inputs: {},
+ status: WorkflowRunningStatus.Succeeded,
+ outputs: {},
+ elapsed_time: 1,
+ total_tokens: 2,
+ total_steps: 3,
+ created_at: 100,
+ finished_at: 120,
+ created_by_account: {
+ id: 'user-1',
+ name: 'Alice',
+ email: 'alice@example.com',
+ },
+ ...overrides,
+})
+
+describe('ViewHistory', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsChatMode = false
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: { data: [] } satisfies WorkflowRunHistoryResponse,
+ isLoading: false,
+ })
+ })
+
+ it('defers fetching until the history popup is opened and renders the empty state', () => {
+ renderWorkflowComponent(, {
+ hooksStoreProps: {
+ handleBackupDraft: vi.fn(),
+ },
+ })
+
+ expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
+
+ expect(mockUseWorkflowRunHistory).toHaveBeenLastCalledWith('/history', true)
+ expect(screen.getByText('workflow.common.notRunning')).toBeInTheDocument()
+ expect(screen.getByText('workflow.common.showRunHistory')).toBeInTheDocument()
+ })
+
+ it('renders the icon trigger variant and loading state, and clears log modals on trigger click', () => {
+ const onClearLogAndMessageModal = vi.fn()
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: { data: [] } satisfies WorkflowRunHistoryResponse,
+ isLoading: true,
+ })
+
+ renderWorkflowComponent(
+ ,
+ {
+ hooksStoreProps: {
+ handleBackupDraft: vi.fn(),
+ },
+ },
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.viewRunHistory' }))
+
+ expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ it('renders workflow run history items and updates the workflow store when one is selected', () => {
+ const handleBackupDraft = vi.fn()
+ const pausedRun = createHistoryItem({
+ id: 'run-paused',
+ status: WorkflowRunningStatus.Paused,
+ created_at: 101,
+ finished_at: 0,
+ })
+ const failedRun = createHistoryItem({
+ id: 'run-failed',
+ status: WorkflowRunningStatus.Failed,
+ created_at: 102,
+ finished_at: 130,
+ })
+ const succeededRun = createHistoryItem({
+ id: 'run-succeeded',
+ status: WorkflowRunningStatus.Succeeded,
+ created_at: 103,
+ finished_at: 140,
+ })
+
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: {
+ data: [pausedRun, failedRun, succeededRun],
+ } satisfies WorkflowRunHistoryResponse,
+ isLoading: false,
+ })
+
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: failedRun,
+ showInputsPanel: true,
+ showEnvPanel: true,
+ controlMode: ControlMode.Pointer,
+ },
+ hooksStoreProps: {
+ handleBackupDraft,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
+
+ expect(screen.getByText('Test Run (paused)')).toBeInTheDocument()
+ expect(screen.getByText('Test Run (failed)')).toBeInTheDocument()
+ expect(screen.getByText('Test Run (succeeded)')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('Test Run (succeeded)'))
+
+ expect(store.getState().historyWorkflowData).toEqual(succeededRun)
+ expect(store.getState().showInputsPanel).toBe(false)
+ expect(store.getState().showEnvPanel).toBe(false)
+ expect(store.getState().controlMode).toBe(ControlMode.Hand)
+ expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
+ expect(handleBackupDraft).toHaveBeenCalledTimes(1)
+ expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
+ expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
+ })
+
+ it('renders chat history labels without workflow status icons in chat mode', () => {
+ mockIsChatMode = true
+ const chatRun = createHistoryItem({
+ id: 'chat-run',
+ status: WorkflowRunningStatus.Failed,
+ })
+
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: {
+ data: [chatRun],
+ } satisfies WorkflowRunHistoryResponse,
+ isLoading: false,
+ })
+
+ renderWorkflowComponent(, {
+ hooksStoreProps: {
+ handleBackupDraft: vi.fn(),
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
+
+ expect(screen.getByText('Test Chat (failed)')).toBeInTheDocument()
+ })
+
+ it('closes the popup from the close button and clears log modals', () => {
+ const onClearLogAndMessageModal = vi.fn()
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: { data: [] } satisfies WorkflowRunHistoryResponse,
+ isLoading: false,
+ })
+
+ renderWorkflowComponent(
+ ,
+ {
+ hooksStoreProps: {
+ handleBackupDraft: vi.fn(),
+ },
+ },
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
+
+ expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/header/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 }) => (