& 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 (
-
- handleEdgeContextMenu(e as never, rfState.edges.find(edge => edge.id === 'e1') as never)}
- >
- edge-e1
-
- handleEdgeContextMenu(e as never, rfState.edges.find(edge => edge.id === 'e2') as never)}
- >
- edge-e2
-
-
-
- )
- }
-
- 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 }) => {children} ,
}))
-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 }) => (
onItemClick(item)}>
{item.title}
diff --git a/web/app/components/workflow/header/checklist/node-group.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx
similarity index 90%
rename from web/app/components/workflow/header/checklist/node-group.spec.tsx
rename to web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx
index 25f54211b4..d574dda0ac 100644
--- a/web/app/components/workflow/header/checklist/node-group.spec.tsx
+++ b/web/app/components/workflow/header/checklist/__tests__/node-group.spec.tsx
@@ -1,12 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react'
-import { BlockEnum } from '../../types'
-import { ChecklistNodeGroup } from './node-group'
+import { BlockEnum } from '../../../types'
+import { ChecklistNodeGroup } from '../node-group'
-vi.mock('../../block-icon', () => ({
+vi.mock('../../../block-icon', () => ({
default: () =>
,
}))
-vi.mock('./item-indicator', () => ({
+vi.mock('../item-indicator', () => ({
ItemIndicator: () =>
,
}))
diff --git a/web/app/components/workflow/header/checklist/plugin-group.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx
similarity index 92%
rename from web/app/components/workflow/header/checklist/plugin-group.spec.tsx
rename to web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx
index 1d9f0dc7d6..275c8727d1 100644
--- a/web/app/components/workflow/header/checklist/plugin-group.spec.tsx
+++ b/web/app/components/workflow/header/checklist/__tests__/plugin-group.spec.tsx
@@ -1,10 +1,10 @@
-import type { ChecklistItem } from '../../hooks/use-checklist'
+import type { ChecklistItem } from '../../../hooks/use-checklist'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { Popover, PopoverContent } from '@/app/components/base/ui/popover'
-import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
-import { BlockEnum } from '../../types'
-import { ChecklistPluginGroup } from './plugin-group'
+import { useStore as usePluginDependencyStore } from '../../../plugin-dependency/store'
+import { BlockEnum } from '../../../types'
+import { ChecklistPluginGroup } from '../plugin-group'
const createChecklistItem = (overrides: Partial = {}): ChecklistItem => ({
id: 'node-1',
diff --git a/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts b/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts
index cad77c3af8..9f5f2da6a7 100644
--- a/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts
@@ -1,10 +1,17 @@
-import { renderHook } from '@testing-library/react'
-import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
+import type { Node } from '../../types'
+import { act, waitFor } from '@testing-library/react'
+import { useNodes } from 'reactflow'
+import { createNode } from '../../__tests__/fixtures'
+import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useAutoGenerateWebhookUrl } from '../use-auto-generate-webhook-url'
-vi.mock('reactflow', async () =>
- (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
+type WebhookFlowNode = Node & {
+ data: NonNullable & {
+ webhook_url?: string
+ webhook_debug_url?: string
+ }
+}
vi.mock('@/app/components/app/store', async () =>
(await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' }))
@@ -15,13 +22,29 @@ vi.mock('@/service/apps', () => ({
}))
describe('useAutoGenerateWebhookUrl', () => {
+ const createFlowNodes = (): WebhookFlowNode[] => [
+ createNode({
+ id: 'webhook-1',
+ data: { type: BlockEnum.TriggerWebhook, webhook_url: '' },
+ }) as WebhookFlowNode,
+ createNode({
+ id: 'code-1',
+ position: { x: 300, y: 0 },
+ data: { type: BlockEnum.Code },
+ }) as WebhookFlowNode,
+ ]
+
+ const renderAutoGenerateWebhookUrlHook = () =>
+ renderWorkflowFlowHook(() => ({
+ autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(),
+ nodes: useNodes(),
+ }), {
+ nodes: createFlowNodes(),
+ edges: [],
+ })
+
beforeEach(() => {
vi.clearAllMocks()
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'webhook-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.TriggerWebhook, webhook_url: '' } },
- { id: 'code-1', position: { x: 300, y: 0 }, data: { type: BlockEnum.Code } },
- ]
})
it('should fetch and set webhook URL for a webhook trigger node', async () => {
@@ -30,38 +53,63 @@ describe('useAutoGenerateWebhookUrl', () => {
webhook_debug_url: 'https://example.com/webhook-debug',
})
- const { result } = renderHook(() => useAutoGenerateWebhookUrl())
- await result.current('webhook-1')
+ const { result } = renderAutoGenerateWebhookUrlHook()
+
+ await act(async () => {
+ await result.current.autoGenerateWebhookUrl('webhook-1')
+ })
expect(mockFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-123', nodeId: 'webhook-1' })
- expect(rfState.setNodes).toHaveBeenCalledOnce()
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- const webhookNode = updatedNodes.find((n: { id: string }) => n.id === 'webhook-1')
- expect(webhookNode.data.webhook_url).toBe('https://example.com/webhook')
- expect(webhookNode.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
+ await waitFor(() => {
+ const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined
+ expect(webhookNode?.data.webhook_url).toBe('https://example.com/webhook')
+ expect(webhookNode?.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
+ })
})
it('should not fetch when node is not a webhook trigger', async () => {
- const { result } = renderHook(() => useAutoGenerateWebhookUrl())
- await result.current('code-1')
+ const { result } = renderAutoGenerateWebhookUrlHook()
+
+ await act(async () => {
+ await result.current.autoGenerateWebhookUrl('code-1')
+ })
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
- expect(rfState.setNodes).not.toHaveBeenCalled()
+
+ const codeNode = result.current.nodes.find(node => node.id === 'code-1') as WebhookFlowNode | undefined
+ expect(codeNode?.data.webhook_url).toBeUndefined()
})
it('should not fetch when node does not exist', async () => {
- const { result } = renderHook(() => useAutoGenerateWebhookUrl())
- await result.current('nonexistent')
+ const { result } = renderAutoGenerateWebhookUrlHook()
+
+ await act(async () => {
+ await result.current.autoGenerateWebhookUrl('nonexistent')
+ })
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
it('should not fetch when webhook_url already exists', async () => {
- rfState.nodes[0].data.webhook_url = 'https://existing.com/webhook'
+ const { result } = renderWorkflowFlowHook(() => ({
+ autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(),
+ }), {
+ nodes: [
+ createNode({
+ id: 'webhook-1',
+ data: {
+ type: BlockEnum.TriggerWebhook,
+ webhook_url: 'https://existing.com/webhook',
+ },
+ }) as WebhookFlowNode,
+ ],
+ edges: [],
+ })
- const { result } = renderHook(() => useAutoGenerateWebhookUrl())
- await result.current('webhook-1')
+ await act(async () => {
+ await result.current.autoGenerateWebhookUrl('webhook-1')
+ })
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
@@ -70,14 +118,18 @@ describe('useAutoGenerateWebhookUrl', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchWebhookUrl.mockRejectedValue(new Error('network error'))
- const { result } = renderHook(() => useAutoGenerateWebhookUrl())
- await result.current('webhook-1')
+ const { result } = renderAutoGenerateWebhookUrlHook()
+
+ await act(async () => {
+ await result.current.autoGenerateWebhookUrl('webhook-1')
+ })
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to auto-generate webhook URL:',
expect.any(Error),
)
- expect(rfState.setNodes).not.toHaveBeenCalled()
+ const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined
+ expect(webhookNode?.data.webhook_url).toBe('')
consoleSpy.mockRestore()
})
})
diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts
index c596be0a4b..6c5433cbab 100644
--- a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts
@@ -1,10 +1,9 @@
-import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
-import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
+import { act, waitFor } from '@testing-library/react'
+import { useEdges, useNodes } from 'reactflow'
+import { createEdge, createNode } from '../../__tests__/fixtures'
+import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { useEdgesInteractions } from '../use-edges-interactions'
-vi.mock('reactflow', async () =>
- (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
-
// useWorkflowHistory uses a debounced save — mock for synchronous assertions
const mockSaveStateToHistory = vi.fn()
vi.mock('../use-workflow-history', () => ({
@@ -28,12 +27,67 @@ vi.mock('../../utils', () => ({
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
}))
-// useNodesSyncDraft is used REAL — via renderWorkflowHook + hooksStoreProps
-function renderEdgesInteractions() {
+type EdgeRuntimeState = {
+ _hovering?: boolean
+ _isBundled?: boolean
+}
+
+type NodeRuntimeState = {
+ selected?: boolean
+ _isBundled?: boolean
+}
+
+const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
+ (edge?.data ?? {}) as EdgeRuntimeState
+
+const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
+ (node?.data ?? {}) as NodeRuntimeState
+
+function createFlowNodes() {
+ return [
+ createNode({ id: 'n1' }),
+ createNode({ id: 'n2', position: { x: 100, y: 0 } }),
+ ]
+}
+
+function createFlowEdges() {
+ return [
+ createEdge({
+ id: 'e1',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-a',
+ data: { _hovering: false },
+ }),
+ createEdge({
+ id: 'e2',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-b',
+ data: { _hovering: false },
+ }),
+ ]
+}
+
+function renderEdgesInteractions(options?: {
+ nodes?: ReturnType
+ edges?: ReturnType
+ initialStoreState?: Record
+}) {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
+ const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {}
+
return {
- ...renderWorkflowHook(() => useEdgesInteractions(), {
+ ...renderWorkflowFlowHook(() => ({
+ ...useEdgesInteractions(),
+ nodes: useNodes(),
+ edges: useEdges(),
+ }), {
+ nodes,
+ edges,
+ initialStoreState,
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
+ reactFlowProps: { fitView: false },
}),
mockDoSync,
}
@@ -42,73 +96,105 @@ function renderEdgesInteractions() {
describe('useEdgesInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
- resetReactFlowMockState()
mockReadOnly = false
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: {} },
- { id: 'n2', position: { x: 100, y: 0 }, data: {} },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false } },
- { id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false } },
- ]
})
- it('handleEdgeEnter should set _hovering to true', () => {
+ it('handleEdgeEnter should set _hovering to true', async () => {
const { result } = renderEdgesInteractions()
- result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
- const updated = rfState.setEdges.mock.calls[0][0]
- expect(updated.find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(true)
- expect(updated.find((e: { id: string }) => e.id === 'e2').data._hovering).toBe(false)
+ act(() => {
+ result.current.handleEdgeEnter({} as never, result.current.edges[0] as never)
+ })
+
+ await waitFor(() => {
+ expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(true)
+ expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e2'))._hovering).toBe(false)
+ })
})
- it('handleEdgeLeave should set _hovering to false', () => {
- rfState.edges[0].data._hovering = true
+ it('handleEdgeLeave should set _hovering to false', async () => {
+ const { result } = renderEdgesInteractions({
+ edges: createFlowEdges().map(edge =>
+ edge.id === 'e1'
+ ? createEdge({ ...edge, data: { ...edge.data, _hovering: true } })
+ : edge,
+ ),
+ })
+
+ act(() => {
+ result.current.handleEdgeLeave({} as never, result.current.edges[0] as never)
+ })
+
+ await waitFor(() => {
+ expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(false)
+ })
+ })
+
+ it('handleEdgesChange should update edge.selected for select changes', async () => {
const { result } = renderEdgesInteractions()
- result.current.handleEdgeLeave({} as never, rfState.edges[0] as never)
- expect(rfState.setEdges.mock.calls[0][0].find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(false)
+ act(() => {
+ result.current.handleEdgesChange([
+ { type: 'select', id: 'e1', selected: true },
+ { type: 'select', id: 'e2', selected: false },
+ ])
+ })
+
+ await waitFor(() => {
+ expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(true)
+ expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(false)
+ })
})
- it('handleEdgesChange should update edge.selected for select changes', () => {
- const { result } = renderEdgesInteractions()
- result.current.handleEdgesChange([
- { type: 'select', id: 'e1', selected: true },
- { type: 'select', id: 'e2', selected: false },
- ])
-
- const updated = rfState.setEdges.mock.calls[0][0]
- expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(true)
- expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false)
- })
-
- it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', () => {
+ it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', async () => {
const preventDefault = vi.fn()
- const { result, store } = renderEdgesInteractions()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: { selected: true, _isBundled: true }, selected: true } as typeof rfState.nodes[number] & { selected: boolean },
- { id: 'n2', position: { x: 100, y: 0 }, data: { _isBundled: true } },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false, _isBundled: true } },
- { id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false, _isBundled: true } },
- ]
+ const { result, store } = renderEdgesInteractions({
+ nodes: [
+ createNode({
+ id: 'n1',
+ data: { selected: true, _isBundled: true },
+ selected: true,
+ }),
+ createNode({
+ id: 'n2',
+ position: { x: 100, y: 0 },
+ data: { _isBundled: true },
+ }),
+ ],
+ edges: [
+ createEdge({
+ id: 'e1',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-a',
+ data: { _hovering: false, _isBundled: true },
+ }),
+ createEdge({
+ id: 'e2',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-b',
+ data: { _hovering: false, _isBundled: true },
+ }),
+ ],
+ })
- result.current.handleEdgeContextMenu({
- preventDefault,
- clientX: 320,
- clientY: 180,
- } as never, rfState.edges[1] as never)
+ act(() => {
+ result.current.handleEdgeContextMenu({
+ preventDefault,
+ clientX: 320,
+ clientY: 180,
+ } as never, result.current.edges[1] as never)
+ })
expect(preventDefault).toHaveBeenCalled()
- const updated = rfState.setEdges.mock.calls[0][0]
- expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(false)
- expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(true)
- expect(updated.every((e: { data: { _isBundled?: boolean } }) => !e.data._isBundled)).toBe(true)
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- expect(updatedNodes.every((node: { data: { selected?: boolean, _isBundled?: boolean }, selected?: boolean }) => !node.data.selected && !node.selected && !node.data._isBundled)).toBe(true)
+ await waitFor(() => {
+ expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(false)
+ expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(true)
+ expect(result.current.edges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true)
+ expect(result.current.nodes.every(node => !getNodeRuntimeState(node).selected && !node.selected && !getNodeRuntimeState(node)._isBundled)).toBe(true)
+ })
expect(store.getState().edgeMenu).toEqual({
clientX: 320,
@@ -120,70 +206,133 @@ describe('useEdgesInteractions', () => {
expect(store.getState().selectionMenu).toBeUndefined()
})
- it('handleEdgeDelete should remove selected edge and trigger sync + history', () => {
- ;(rfState.edges[0] as Record).selected = true
- const { result, store } = renderEdgesInteractions()
- store.setState({
- edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
+ it('handleEdgeDelete should remove selected edge and trigger sync + history', async () => {
+ const { result, store } = renderEdgesInteractions({
+ edges: [
+ createEdge({
+ id: 'e1',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-a',
+ selected: true,
+ data: { _hovering: false },
+ }),
+ createEdge({
+ id: 'e2',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-b',
+ data: { _hovering: false },
+ }),
+ ],
+ initialStoreState: {
+ edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
+ },
})
- result.current.handleEdgeDelete()
+ act(() => {
+ result.current.handleEdgeDelete()
+ })
+
+ await waitFor(() => {
+ expect(result.current.edges).toHaveLength(1)
+ expect(result.current.edges[0]?.id).toBe('e2')
+ })
- const updated = rfState.setEdges.mock.calls[0][0]
- expect(updated).toHaveLength(1)
- expect(updated[0].id).toBe('e2')
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('handleEdgeDelete should do nothing when no edge is selected', () => {
const { result } = renderEdgesInteractions()
- result.current.handleEdgeDelete()
- expect(rfState.setEdges).not.toHaveBeenCalled()
- })
- it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', () => {
- ;(rfState.edges[0] as Record).selected = true
- const { result, store } = renderEdgesInteractions()
- store.setState({
- edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' },
+ act(() => {
+ result.current.handleEdgeDelete()
})
- result.current.handleEdgeDeleteById('e2')
+ expect(result.current.edges).toHaveLength(2)
+ })
+
+ it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', async () => {
+ const { result, store } = renderEdgesInteractions({
+ edges: [
+ createEdge({
+ id: 'e1',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-a',
+ selected: true,
+ data: { _hovering: false },
+ }),
+ createEdge({
+ id: 'e2',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-b',
+ data: { _hovering: false },
+ }),
+ ],
+ initialStoreState: {
+ edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' },
+ },
+ })
+
+ act(() => {
+ result.current.handleEdgeDeleteById('e2')
+ })
+
+ await waitFor(() => {
+ expect(result.current.edges).toHaveLength(1)
+ expect(result.current.edges[0]?.id).toBe('e1')
+ expect(result.current.edges[0]?.selected).toBe(true)
+ })
- const updated = rfState.setEdges.mock.calls[0][0]
- expect(updated).toHaveLength(1)
- expect(updated[0].id).toBe('e1')
- expect(updated[0].selected).toBe(true)
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
- it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', () => {
- const { result, store } = renderEdgesInteractions()
- store.setState({
- edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
+ it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', async () => {
+ const { result, store } = renderEdgesInteractions({
+ initialStoreState: {
+ edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
+ },
+ })
+
+ act(() => {
+ result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
+ })
+
+ await waitFor(() => {
+ expect(result.current.edges).toHaveLength(1)
+ expect(result.current.edges[0]?.id).toBe('e2')
})
- result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
- const updated = rfState.setEdges.mock.calls[0][0]
- expect(updated).toHaveLength(1)
- expect(updated[0].id).toBe('e2')
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch')
})
- it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', () => {
- rfState.edges = [
- { id: 'n1-old-handle-n2-target', source: 'n1', target: 'n2', sourceHandle: 'old-handle', targetHandle: 'target', data: {} } as typeof rfState.edges[0],
- ]
+ it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', async () => {
+ const { result } = renderEdgesInteractions({
+ edges: [
+ createEdge({
+ id: 'n1-old-handle-n2-target',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'old-handle',
+ targetHandle: 'target',
+ data: {},
+ }),
+ ],
+ })
- const { result } = renderEdgesInteractions()
- result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
+ act(() => {
+ result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
+ })
- const updated = rfState.setEdges.mock.calls[0][0]
- expect(updated[0].sourceHandle).toBe('new-handle')
- expect(updated[0].id).toBe('n1-new-handle-n2-target')
+ await waitFor(() => {
+ expect(result.current.edges[0]?.sourceHandle).toBe('new-handle')
+ expect(result.current.edges[0]?.id).toBe('n1-new-handle-n2-target')
+ })
})
describe('read-only mode', () => {
@@ -193,38 +342,75 @@ describe('useEdgesInteractions', () => {
it('handleEdgeEnter should do nothing', () => {
const { result } = renderEdgesInteractions()
- result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
- expect(rfState.setEdges).not.toHaveBeenCalled()
+
+ act(() => {
+ result.current.handleEdgeEnter({} as never, result.current.edges[0] as never)
+ })
+
+ expect(getEdgeRuntimeState(result.current.edges[0])._hovering).toBe(false)
})
it('handleEdgeDelete should do nothing', () => {
- ;(rfState.edges[0] as Record).selected = true
- const { result } = renderEdgesInteractions()
- result.current.handleEdgeDelete()
- expect(rfState.setEdges).not.toHaveBeenCalled()
+ const { result } = renderEdgesInteractions({
+ edges: [
+ createEdge({
+ id: 'e1',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-a',
+ selected: true,
+ data: { _hovering: false },
+ }),
+ createEdge({
+ id: 'e2',
+ source: 'n1',
+ target: 'n2',
+ sourceHandle: 'branch-b',
+ data: { _hovering: false },
+ }),
+ ],
+ })
+
+ act(() => {
+ result.current.handleEdgeDelete()
+ })
+
+ expect(result.current.edges).toHaveLength(2)
})
it('handleEdgeDeleteById should do nothing', () => {
const { result } = renderEdgesInteractions()
- result.current.handleEdgeDeleteById('e1')
- expect(rfState.setEdges).not.toHaveBeenCalled()
+
+ act(() => {
+ result.current.handleEdgeDeleteById('e1')
+ })
+
+ expect(result.current.edges).toHaveLength(2)
})
it('handleEdgeContextMenu should do nothing', () => {
const { result, store } = renderEdgesInteractions()
- result.current.handleEdgeContextMenu({
- preventDefault: vi.fn(),
- clientX: 200,
- clientY: 120,
- } as never, rfState.edges[0] as never)
- expect(rfState.setEdges).not.toHaveBeenCalled()
+
+ act(() => {
+ result.current.handleEdgeContextMenu({
+ preventDefault: vi.fn(),
+ clientX: 200,
+ clientY: 120,
+ } as never, result.current.edges[0] as never)
+ })
+
+ expect(result.current.edges.every(edge => !edge.selected)).toBe(true)
expect(store.getState().edgeMenu).toBeUndefined()
})
it('handleEdgeDeleteByDeleteBranch should do nothing', () => {
const { result } = renderEdgesInteractions()
- result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
- expect(rfState.setEdges).not.toHaveBeenCalled()
+
+ act(() => {
+ result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
+ })
+
+ expect(result.current.edges).toHaveLength(2)
})
})
})
diff --git a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts
index 4c4eb010e6..31d5d82475 100644
--- a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts
@@ -1,58 +1,52 @@
import type * as React from 'react'
-import type { Node, OnSelectionChangeParams } from 'reactflow'
-import type { MockEdge, MockNode } from '../../__tests__/reactflow-mock-state'
-import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
-import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
+import type { OnSelectionChangeParams } from 'reactflow'
+import { act, waitFor } from '@testing-library/react'
+import { useEdges, useNodes, useStoreApi } from 'reactflow'
+import { createEdge, createNode } from '../../__tests__/fixtures'
+import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { useSelectionInteractions } from '../use-selection-interactions'
-const rfStoreExtra = vi.hoisted(() => ({
- userSelectionRect: null as { x: number, y: number, width: number, height: number } | null,
- userSelectionActive: false,
- resetSelectedElements: vi.fn(),
- setState: vi.fn(),
-}))
+type BundledState = {
+ _isBundled?: boolean
+}
-vi.mock('reactflow', async () => {
- const mod = await import('../../__tests__/reactflow-mock-state')
- const base = mod.createReactFlowModuleMock()
- return {
- ...base,
- useStoreApi: vi.fn(() => ({
- getState: () => ({
- getNodes: () => mod.rfState.nodes,
- setNodes: mod.rfState.setNodes,
- edges: mod.rfState.edges,
- setEdges: mod.rfState.setEdges,
- transform: mod.rfState.transform,
- userSelectionRect: rfStoreExtra.userSelectionRect,
- userSelectionActive: rfStoreExtra.userSelectionActive,
- resetSelectedElements: rfStoreExtra.resetSelectedElements,
- }),
- setState: rfStoreExtra.setState,
- subscribe: vi.fn().mockReturnValue(vi.fn()),
- })),
- }
-})
+const getBundledState = (item?: { data?: unknown }): BundledState =>
+ (item?.data ?? {}) as BundledState
+
+function createFlowNodes() {
+ return [
+ createNode({ id: 'n1', data: { _isBundled: true } }),
+ createNode({ id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } }),
+ createNode({ id: 'n3', position: { x: 200, y: 200 }, data: {} }),
+ ]
+}
+
+function createFlowEdges() {
+ return [
+ createEdge({ id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } }),
+ createEdge({ id: 'e2', source: 'n2', target: 'n3', data: {} }),
+ ]
+}
+
+function renderSelectionInteractions(initialStoreState?: Record) {
+ return renderWorkflowFlowHook(() => ({
+ ...useSelectionInteractions(),
+ nodes: useNodes(),
+ edges: useEdges(),
+ reactFlowStore: useStoreApi(),
+ }), {
+ nodes: createFlowNodes(),
+ edges: createFlowEdges(),
+ reactFlowProps: { fitView: false },
+ initialStoreState,
+ })
+}
describe('useSelectionInteractions', () => {
let container: HTMLDivElement
beforeEach(() => {
- resetReactFlowMockState()
- rfStoreExtra.userSelectionRect = null
- rfStoreExtra.userSelectionActive = false
- rfStoreExtra.resetSelectedElements = vi.fn()
- rfStoreExtra.setState.mockReset()
-
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: { _isBundled: true } },
- { id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } },
- { id: 'n3', position: { x: 200, y: 200 }, data: {} },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } },
- { id: 'e2', source: 'n2', target: 'n3', data: {} },
- ]
+ vi.clearAllMocks()
container = document.createElement('div')
container.id = 'workflow-container'
@@ -73,110 +67,137 @@ describe('useSelectionInteractions', () => {
container.remove()
})
- it('handleSelectionStart should clear _isBundled from all nodes and edges', () => {
- const { result } = renderWorkflowHook(() => useSelectionInteractions())
+ it('handleSelectionStart should clear _isBundled from all nodes and edges', async () => {
+ const { result } = renderSelectionInteractions()
- result.current.handleSelectionStart()
+ act(() => {
+ result.current.handleSelectionStart()
+ })
- const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
- expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
-
- const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
- expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
+ await waitFor(() => {
+ expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true)
+ expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true)
+ })
})
- it('handleSelectionChange should mark selected nodes as bundled', () => {
- rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
+ it('handleSelectionChange should mark selected nodes as bundled', async () => {
+ const { result } = renderSelectionInteractions()
- const { result } = renderWorkflowHook(() => useSelectionInteractions())
+ act(() => {
+ result.current.reactFlowStore.setState({
+ userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
+ } as never)
+ })
- result.current.handleSelectionChange({
- nodes: [{ id: 'n1' }, { id: 'n3' }],
- edges: [],
- } as unknown as OnSelectionChangeParams)
+ act(() => {
+ result.current.handleSelectionChange({
+ nodes: [{ id: 'n1' }, { id: 'n3' }],
+ edges: [],
+ } as unknown as OnSelectionChangeParams)
+ })
- const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
- expect(updatedNodes.find(n => n.id === 'n1')!.data._isBundled).toBe(true)
- expect(updatedNodes.find(n => n.id === 'n2')!.data._isBundled).toBe(false)
- expect(updatedNodes.find(n => n.id === 'n3')!.data._isBundled).toBe(true)
+ await waitFor(() => {
+ expect(getBundledState(result.current.nodes.find(node => node.id === 'n1'))._isBundled).toBe(true)
+ expect(getBundledState(result.current.nodes.find(node => node.id === 'n2'))._isBundled).toBe(false)
+ expect(getBundledState(result.current.nodes.find(node => node.id === 'n3'))._isBundled).toBe(true)
+ })
})
- it('handleSelectionChange should mark selected edges', () => {
- rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
+ it('handleSelectionChange should mark selected edges', async () => {
+ const { result } = renderSelectionInteractions()
- const { result } = renderWorkflowHook(() => useSelectionInteractions())
+ act(() => {
+ result.current.reactFlowStore.setState({
+ userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
+ } as never)
+ })
- result.current.handleSelectionChange({
- nodes: [],
- edges: [{ id: 'e1' }],
- } as unknown as OnSelectionChangeParams)
+ act(() => {
+ result.current.handleSelectionChange({
+ nodes: [],
+ edges: [{ id: 'e1' }],
+ } as unknown as OnSelectionChangeParams)
+ })
- const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
- expect(updatedEdges.find(e => e.id === 'e1')!.data._isBundled).toBe(true)
- expect(updatedEdges.find(e => e.id === 'e2')!.data._isBundled).toBe(false)
+ await waitFor(() => {
+ expect(getBundledState(result.current.edges.find(edge => edge.id === 'e1'))._isBundled).toBe(true)
+ expect(getBundledState(result.current.edges.find(edge => edge.id === 'e2'))._isBundled).toBe(false)
+ })
})
- it('handleSelectionDrag should sync node positions', () => {
- const { result, store } = renderWorkflowHook(() => useSelectionInteractions())
-
+ it('handleSelectionDrag should sync node positions', async () => {
+ const { result, store } = renderSelectionInteractions()
const draggedNodes = [
{ id: 'n1', position: { x: 50, y: 60 }, data: {} },
- ] as unknown as Node[]
+ ] as never
- result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes)
+ act(() => {
+ result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes)
+ })
expect(store.getState().nodeAnimation).toBe(false)
- const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
- expect(updatedNodes.find(n => n.id === 'n1')!.position).toEqual({ x: 50, y: 60 })
- expect(updatedNodes.find(n => n.id === 'n2')!.position).toEqual({ x: 100, y: 100 })
+ await waitFor(() => {
+ expect(result.current.nodes.find(node => node.id === 'n1')?.position).toEqual({ x: 50, y: 60 })
+ expect(result.current.nodes.find(node => node.id === 'n2')?.position).toEqual({ x: 100, y: 100 })
+ })
})
- it('handleSelectionCancel should clear all selection state', () => {
- const { result } = renderWorkflowHook(() => useSelectionInteractions())
+ it('handleSelectionCancel should clear all selection state', async () => {
+ const { result } = renderSelectionInteractions()
- result.current.handleSelectionCancel()
-
- expect(rfStoreExtra.setState).toHaveBeenCalledWith({
- userSelectionRect: null,
- userSelectionActive: true,
+ act(() => {
+ result.current.reactFlowStore.setState({
+ userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
+ userSelectionActive: false,
+ } as never)
})
- const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
- expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
+ act(() => {
+ result.current.handleSelectionCancel()
+ })
- const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
- expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
+ expect(result.current.reactFlowStore.getState().userSelectionRect).toBeNull()
+ expect(result.current.reactFlowStore.getState().userSelectionActive).toBe(true)
+
+ await waitFor(() => {
+ expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true)
+ expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true)
+ })
})
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
- const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
- initialStoreState: {
- nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
- panelMenu: { top: 30, left: 40 },
- edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
- },
+ const { result, store } = renderSelectionInteractions({
+ nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
+ panelMenu: { top: 30, left: 40 },
+ edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
})
const wrongTarget = document.createElement('div')
wrongTarget.classList.add('some-other-class')
- result.current.handleSelectionContextMenu({
- target: wrongTarget,
- preventDefault: vi.fn(),
- clientX: 300,
- clientY: 200,
- } as unknown as React.MouseEvent)
+
+ act(() => {
+ result.current.handleSelectionContextMenu({
+ target: wrongTarget,
+ preventDefault: vi.fn(),
+ clientX: 300,
+ clientY: 200,
+ } as unknown as React.MouseEvent)
+ })
expect(store.getState().selectionMenu).toBeUndefined()
const correctTarget = document.createElement('div')
correctTarget.classList.add('react-flow__nodesselection-rect')
- result.current.handleSelectionContextMenu({
- target: correctTarget,
- preventDefault: vi.fn(),
- clientX: 300,
- clientY: 200,
- } as unknown as React.MouseEvent)
+
+ act(() => {
+ result.current.handleSelectionContextMenu({
+ target: correctTarget,
+ preventDefault: vi.fn(),
+ clientX: 300,
+ clientY: 200,
+ } as unknown as React.MouseEvent)
+ })
expect(store.getState().selectionMenu).toEqual({
top: 150,
@@ -188,11 +209,13 @@ describe('useSelectionInteractions', () => {
})
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
- const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
- initialStoreState: { selectionMenu: { top: 50, left: 60 } },
+ const { result, store } = renderSelectionInteractions({
+ selectionMenu: { top: 50, left: 60 },
})
- result.current.handleSelectionContextmenuCancel()
+ act(() => {
+ result.current.handleSelectionContextmenuCancel()
+ })
expect(store.getState().selectionMenu).toBeUndefined()
})
diff --git a/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts
index 9544c401cf..2d40028226 100644
--- a/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts
@@ -1,130 +1,209 @@
-import { renderHook } from '@testing-library/react'
-import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
+import { act, waitFor } from '@testing-library/react'
+import { useEdges, useNodes } from 'reactflow'
+import { createEdge, createNode } from '../../__tests__/fixtures'
+import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { NodeRunningStatus } from '../../types'
import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync'
-vi.mock('reactflow', async () =>
- (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
+type EdgeRuntimeState = {
+ _sourceRunningStatus?: NodeRunningStatus
+ _targetRunningStatus?: NodeRunningStatus
+ _waitingRun?: boolean
+}
+
+type NodeRuntimeState = {
+ _runningStatus?: NodeRunningStatus
+ _waitingRun?: boolean
+}
+
+const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
+ (edge?.data ?? {}) as EdgeRuntimeState
+
+const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
+ (node?.data ?? {}) as NodeRuntimeState
describe('useEdgesInteractionsWithoutSync', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.edges = [
- { id: 'e1', source: 'a', target: 'b', data: { _sourceRunningStatus: 'running', _targetRunningStatus: 'running', _waitingRun: true } },
- { id: 'e2', source: 'b', target: 'c', data: { _sourceRunningStatus: 'succeeded', _targetRunningStatus: undefined, _waitingRun: false } },
- ]
- })
+ const createFlowNodes = () => [
+ createNode({ id: 'a' }),
+ createNode({ id: 'b' }),
+ createNode({ id: 'c' }),
+ ]
+ const createFlowEdges = () => [
+ createEdge({
+ id: 'e1',
+ source: 'a',
+ target: 'b',
+ data: {
+ _sourceRunningStatus: NodeRunningStatus.Running,
+ _targetRunningStatus: NodeRunningStatus.Running,
+ _waitingRun: true,
+ },
+ }),
+ createEdge({
+ id: 'e2',
+ source: 'b',
+ target: 'c',
+ data: {
+ _sourceRunningStatus: NodeRunningStatus.Succeeded,
+ _targetRunningStatus: undefined,
+ _waitingRun: false,
+ },
+ }),
+ ]
+
+ const renderEdgesInteractionsHook = () =>
+ renderWorkflowFlowHook(() => ({
+ ...useEdgesInteractionsWithoutSync(),
+ edges: useEdges(),
+ }), {
+ nodes: createFlowNodes(),
+ edges: createFlowEdges(),
+ })
it('should clear running status and waitingRun on all edges', () => {
- const { result } = renderHook(() => useEdgesInteractionsWithoutSync())
+ const { result } = renderEdgesInteractionsHook()
- result.current.handleEdgeCancelRunningStatus()
+ act(() => {
+ result.current.handleEdgeCancelRunningStatus()
+ })
- expect(rfState.setEdges).toHaveBeenCalledOnce()
- const updated = rfState.setEdges.mock.calls[0][0]
- for (const edge of updated) {
- expect(edge.data._sourceRunningStatus).toBeUndefined()
- expect(edge.data._targetRunningStatus).toBeUndefined()
- expect(edge.data._waitingRun).toBe(false)
- }
+ return waitFor(() => {
+ result.current.edges.forEach((edge) => {
+ const edgeState = getEdgeRuntimeState(edge)
+ expect(edgeState._sourceRunningStatus).toBeUndefined()
+ expect(edgeState._targetRunningStatus).toBeUndefined()
+ expect(edgeState._waitingRun).toBe(false)
+ })
+ })
})
it('should not mutate original edges', () => {
- const originalData = { ...rfState.edges[0].data }
- const { result } = renderHook(() => useEdgesInteractionsWithoutSync())
+ const edges = createFlowEdges()
+ const originalData = { ...getEdgeRuntimeState(edges[0]) }
+ const { result } = renderWorkflowFlowHook(() => ({
+ ...useEdgesInteractionsWithoutSync(),
+ edges: useEdges(),
+ }), {
+ nodes: createFlowNodes(),
+ edges,
+ })
- result.current.handleEdgeCancelRunningStatus()
+ act(() => {
+ result.current.handleEdgeCancelRunningStatus()
+ })
- expect(rfState.edges[0].data._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
+ expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
})
})
describe('useNodesInteractionsWithoutSync', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } },
- { id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } },
- { id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } },
- ]
- })
+ const createFlowNodes = () => [
+ createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }),
+ createNode({ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }),
+ createNode({ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }),
+ ]
+
+ const renderNodesInteractionsHook = () =>
+ renderWorkflowFlowHook(() => ({
+ ...useNodesInteractionsWithoutSync(),
+ nodes: useNodes(),
+ }), {
+ nodes: createFlowNodes(),
+ edges: [],
+ })
describe('handleNodeCancelRunningStatus', () => {
- it('should clear _runningStatus and _waitingRun on all nodes', () => {
- const { result } = renderHook(() => useNodesInteractionsWithoutSync())
+ it('should clear _runningStatus and _waitingRun on all nodes', async () => {
+ const { result } = renderNodesInteractionsHook()
- result.current.handleNodeCancelRunningStatus()
+ act(() => {
+ result.current.handleNodeCancelRunningStatus()
+ })
- expect(rfState.setNodes).toHaveBeenCalledOnce()
- const updated = rfState.setNodes.mock.calls[0][0]
- for (const node of updated) {
- expect(node.data._runningStatus).toBeUndefined()
- expect(node.data._waitingRun).toBe(false)
- }
+ await waitFor(() => {
+ result.current.nodes.forEach((node) => {
+ const nodeState = getNodeRuntimeState(node)
+ expect(nodeState._runningStatus).toBeUndefined()
+ expect(nodeState._waitingRun).toBe(false)
+ })
+ })
})
})
describe('handleCancelAllNodeSuccessStatus', () => {
- it('should clear _runningStatus only for Succeeded nodes', () => {
- const { result } = renderHook(() => useNodesInteractionsWithoutSync())
+ it('should clear _runningStatus only for Succeeded nodes', async () => {
+ const { result } = renderNodesInteractionsHook()
- result.current.handleCancelAllNodeSuccessStatus()
+ act(() => {
+ result.current.handleCancelAllNodeSuccessStatus()
+ })
- expect(rfState.setNodes).toHaveBeenCalledOnce()
- const updated = rfState.setNodes.mock.calls[0][0]
- const n1 = updated.find((n: { id: string }) => n.id === 'n1')
- const n2 = updated.find((n: { id: string }) => n.id === 'n2')
- const n3 = updated.find((n: { id: string }) => n.id === 'n3')
+ await waitFor(() => {
+ const n1 = result.current.nodes.find(node => node.id === 'n1')
+ const n2 = result.current.nodes.find(node => node.id === 'n2')
+ const n3 = result.current.nodes.find(node => node.id === 'n3')
- expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
- expect(n2.data._runningStatus).toBeUndefined()
- expect(n3.data._runningStatus).toBe(NodeRunningStatus.Failed)
+ expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
+ expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
+ expect(getNodeRuntimeState(n3)._runningStatus).toBe(NodeRunningStatus.Failed)
+ })
})
- it('should not modify _waitingRun', () => {
- const { result } = renderHook(() => useNodesInteractionsWithoutSync())
+ it('should not modify _waitingRun', async () => {
+ const { result } = renderNodesInteractionsHook()
- result.current.handleCancelAllNodeSuccessStatus()
+ act(() => {
+ result.current.handleCancelAllNodeSuccessStatus()
+ })
- const updated = rfState.setNodes.mock.calls[0][0]
- expect(updated.find((n: { id: string }) => n.id === 'n1').data._waitingRun).toBe(true)
- expect(updated.find((n: { id: string }) => n.id === 'n3').data._waitingRun).toBe(true)
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._waitingRun).toBe(true)
+ expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n3'))._waitingRun).toBe(true)
+ })
})
})
describe('handleCancelNodeSuccessStatus', () => {
- it('should clear _runningStatus and _waitingRun for the specified Succeeded node', () => {
- const { result } = renderHook(() => useNodesInteractionsWithoutSync())
+ it('should clear _runningStatus and _waitingRun for the specified Succeeded node', async () => {
+ const { result } = renderNodesInteractionsHook()
- result.current.handleCancelNodeSuccessStatus('n2')
+ act(() => {
+ result.current.handleCancelNodeSuccessStatus('n2')
+ })
- expect(rfState.setNodes).toHaveBeenCalledOnce()
- const updated = rfState.setNodes.mock.calls[0][0]
- const n2 = updated.find((n: { id: string }) => n.id === 'n2')
- expect(n2.data._runningStatus).toBeUndefined()
- expect(n2.data._waitingRun).toBe(false)
+ await waitFor(() => {
+ const n2 = result.current.nodes.find(node => node.id === 'n2')
+ expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
+ expect(getNodeRuntimeState(n2)._waitingRun).toBe(false)
+ })
})
- it('should not modify nodes that are not Succeeded', () => {
- const { result } = renderHook(() => useNodesInteractionsWithoutSync())
+ it('should not modify nodes that are not Succeeded', async () => {
+ const { result } = renderNodesInteractionsHook()
- result.current.handleCancelNodeSuccessStatus('n1')
+ act(() => {
+ result.current.handleCancelNodeSuccessStatus('n1')
+ })
- const updated = rfState.setNodes.mock.calls[0][0]
- const n1 = updated.find((n: { id: string }) => n.id === 'n1')
- expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
- expect(n1.data._waitingRun).toBe(true)
+ await waitFor(() => {
+ const n1 = result.current.nodes.find(node => node.id === 'n1')
+ expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
+ expect(getNodeRuntimeState(n1)._waitingRun).toBe(true)
+ })
})
- it('should not modify other nodes', () => {
- const { result } = renderHook(() => useNodesInteractionsWithoutSync())
+ it('should not modify other nodes', async () => {
+ const { result } = renderNodesInteractionsHook()
- result.current.handleCancelNodeSuccessStatus('n2')
+ act(() => {
+ result.current.handleCancelNodeSuccessStatus('n2')
+ })
- const updated = rfState.setNodes.mock.calls[0][0]
- const n1 = updated.find((n: { id: string }) => n.id === 'n1')
- expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
+ await waitFor(() => {
+ const n1 = result.current.nodes.find(node => node.id === 'n1')
+ expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
+ })
})
})
})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts
index e40efd3819..1c8a0764d1 100644
--- a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts
@@ -7,8 +7,10 @@ import type {
NodeFinishedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
-import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
-import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
+import { act, waitFor } from '@testing-library/react'
+import { useEdges, useNodes } from 'reactflow'
+import { createEdge, createNode } from '../../__tests__/fixtures'
+import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
import { useWorkflowNodeFinished } from '../use-workflow-run-event/use-workflow-node-finished'
@@ -19,44 +21,100 @@ import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-
import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry'
import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started'
-vi.mock('reactflow', async () =>
- (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
+type NodeRuntimeState = {
+ _waitingRun?: boolean
+ _runningStatus?: NodeRunningStatus
+ _retryIndex?: number
+ _iterationIndex?: number
+ _loopIndex?: number
+ _runningBranchId?: string
+}
+
+type EdgeRuntimeState = {
+ _sourceRunningStatus?: NodeRunningStatus
+ _targetRunningStatus?: NodeRunningStatus
+ _waitingRun?: boolean
+}
+
+const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
+ (node?.data ?? {}) as NodeRuntimeState
+
+const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
+ (edge?.data ?? {}) as EdgeRuntimeState
+
+function createRunNodes() {
+ return [
+ createNode({
+ id: 'n1',
+ width: 200,
+ height: 80,
+ data: { _waitingRun: false },
+ }),
+ ]
+}
+
+function createRunEdges() {
+ return [
+ createEdge({
+ id: 'e1',
+ source: 'n0',
+ target: 'n1',
+ data: {},
+ }),
+ ]
+}
+
+function renderRunEventHook>(
+ useHook: () => T,
+ options?: {
+ nodes?: ReturnType
+ edges?: ReturnType
+ initialStoreState?: Record
+ },
+) {
+ const { nodes = createRunNodes(), edges = createRunEdges(), initialStoreState } = options ?? {}
+
+ return renderWorkflowFlowHook(() => ({
+ ...useHook(),
+ nodes: useNodes(),
+ edges: useEdges(),
+ }), {
+ nodes,
+ edges,
+ reactFlowProps: { fitView: false },
+ initialStoreState,
+ })
+}
describe('useWorkflowStarted', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _waitingRun: false } },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n0', target: 'n1', data: {} },
- ]
- })
-
- it('should initialize workflow running data and reset nodes/edges', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
+ it('should initialize workflow running data and reset nodes/edges', async () => {
+ const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
- result.current.handleWorkflowStarted({
- task_id: 'task-2',
- data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
- } as WorkflowStartedResponse)
+ act(() => {
+ result.current.handleWorkflowStarted({
+ task_id: 'task-2',
+ data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
+ } as WorkflowStartedResponse)
+ })
const state = store.getState().workflowRunningData!
expect(state.task_id).toBe('task-2')
expect(state.result.status).toBe(WorkflowRunningStatus.Running)
expect(state.resultText).toBe('')
- expect(rfState.setNodes).toHaveBeenCalledOnce()
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- expect(updatedNodes[0].data._waitingRun).toBe(true)
-
- expect(rfState.setEdges).toHaveBeenCalledOnce()
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(true)
+ expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBeUndefined()
+ expect(getEdgeRuntimeState(result.current.edges[0])._sourceRunningStatus).toBeUndefined()
+ expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBeUndefined()
+ expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBe(true)
+ })
})
it('should resume from Paused without resetting nodes/edges', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
+ const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'],
@@ -64,30 +122,28 @@ describe('useWorkflowStarted', () => {
},
})
- result.current.handleWorkflowStarted({
- task_id: 'task-2',
- data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
- } as WorkflowStartedResponse)
+ act(() => {
+ result.current.handleWorkflowStarted({
+ task_id: 'task-2',
+ data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
+ } as WorkflowStartedResponse)
+ })
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running)
- expect(rfState.setNodes).not.toHaveBeenCalled()
- expect(rfState.setEdges).not.toHaveBeenCalled()
+ expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(false)
+ expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBeUndefined()
})
})
describe('useWorkflowNodeFinished', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n0', target: 'n1', data: {} },
- ]
- })
-
- it('should update tracing and node running status', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
+ it('should update tracing and node running status', async () => {
+ const { result, store } = renderRunEventHook(() => useWorkflowNodeFinished(), {
+ nodes: [
+ createNode({
+ id: 'n1',
+ data: { _runningStatus: NodeRunningStatus.Running },
+ }),
+ ],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
@@ -95,20 +151,29 @@ describe('useWorkflowNodeFinished', () => {
},
})
- result.current.handleWorkflowNodeFinished({
- data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
- } as NodeFinishedResponse)
+ act(() => {
+ result.current.handleWorkflowNodeFinished({
+ data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
+ } as NodeFinishedResponse)
+ })
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
- expect(rfState.setEdges).toHaveBeenCalledOnce()
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
+ expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
+ })
})
- it('should set _runningBranchId for IfElse node', () => {
- const { result } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
+ it('should set _runningBranchId for IfElse node', async () => {
+ const { result } = renderRunEventHook(() => useWorkflowNodeFinished(), {
+ nodes: [
+ createNode({
+ id: 'n1',
+ data: { _runningStatus: NodeRunningStatus.Running },
+ }),
+ ],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
@@ -116,83 +181,75 @@ describe('useWorkflowNodeFinished', () => {
},
})
- result.current.handleWorkflowNodeFinished({
- data: {
- id: 'trace-1',
- node_id: 'n1',
- node_type: 'if-else',
- status: NodeRunningStatus.Succeeded,
- outputs: { selected_case_id: 'branch-a' },
- },
- } as unknown as NodeFinishedResponse)
+ act(() => {
+ result.current.handleWorkflowNodeFinished({
+ data: {
+ id: 'trace-1',
+ node_id: 'n1',
+ node_type: 'if-else',
+ status: NodeRunningStatus.Succeeded,
+ outputs: { selected_case_id: 'branch-a' },
+ },
+ } as unknown as NodeFinishedResponse)
+ })
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- expect(updatedNodes[0].data._runningBranchId).toBe('branch-a')
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBe('branch-a')
+ })
})
})
describe('useWorkflowNodeRetry', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: {} },
- ]
- })
-
- it('should push retry data to tracing and update _retryIndex', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeRetry(), {
+ it('should push retry data to tracing and update _retryIndex', async () => {
+ const { result, store } = renderRunEventHook(() => useWorkflowNodeRetry(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
- result.current.handleWorkflowNodeRetry({
- data: { node_id: 'n1', retry_index: 2 },
- } as NodeFinishedResponse)
+ act(() => {
+ result.current.handleWorkflowNodeRetry({
+ data: { node_id: 'n1', retry_index: 2 },
+ } as NodeFinishedResponse)
+ })
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- expect(updatedNodes[0].data._retryIndex).toBe(2)
+
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes[0])._retryIndex).toBe(2)
+ })
})
})
describe('useWorkflowNodeIterationNext', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: {} },
- ]
- })
-
- it('should set _iterationIndex and increment iterTimes', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationNext(), {
+ it('should set _iterationIndex and increment iterTimes', async () => {
+ const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationNext(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 3,
},
})
- result.current.handleWorkflowNodeIterationNext({
- data: { node_id: 'n1' },
- } as IterationNextResponse)
+ act(() => {
+ result.current.handleWorkflowNodeIterationNext({
+ data: { node_id: 'n1' },
+ } as IterationNextResponse)
+ })
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- expect(updatedNodes[0].data._iterationIndex).toBe(3)
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes[0])._iterationIndex).toBe(3)
+ })
expect(store.getState().iterTimes).toBe(4)
})
})
describe('useWorkflowNodeIterationFinished', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n0', target: 'n1', data: {} },
- ]
- })
-
- it('should update tracing, reset iterTimes, update node status and edges', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationFinished(), {
+ it('should update tracing, reset iterTimes, update node status and edges', async () => {
+ const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationFinished(), {
+ nodes: [
+ createNode({
+ id: 'n1',
+ data: { _runningStatus: NodeRunningStatus.Running },
+ }),
+ ],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }],
@@ -201,56 +258,60 @@ describe('useWorkflowNodeIterationFinished', () => {
},
})
- result.current.handleWorkflowNodeIterationFinished({
- data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
- } as IterationFinishedResponse)
+ act(() => {
+ result.current.handleWorkflowNodeIterationFinished({
+ data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
+ } as IterationFinishedResponse)
+ })
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
- expect(rfState.setEdges).toHaveBeenCalledOnce()
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
+ expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
+ })
})
})
describe('useWorkflowNodeLoopNext', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: {} },
- { id: 'n2', position: { x: 300, y: 0 }, parentId: 'n1', data: { _waitingRun: false } },
- ]
- })
-
- it('should set _loopIndex and reset child nodes to waiting', () => {
- const { result } = renderWorkflowHook(() => useWorkflowNodeLoopNext(), {
+ it('should set _loopIndex and reset child nodes to waiting', async () => {
+ const { result } = renderRunEventHook(() => useWorkflowNodeLoopNext(), {
+ nodes: [
+ createNode({ id: 'n1', data: {} }),
+ createNode({
+ id: 'n2',
+ position: { x: 300, y: 0 },
+ parentId: 'n1',
+ data: { _waitingRun: false },
+ }),
+ ],
+ edges: [],
initialStoreState: { workflowRunningData: baseRunningData() },
})
- result.current.handleWorkflowNodeLoopNext({
- data: { node_id: 'n1', index: 5 },
- } as LoopNextResponse)
+ act(() => {
+ result.current.handleWorkflowNodeLoopNext({
+ data: { node_id: 'n1', index: 5 },
+ } as LoopNextResponse)
+ })
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- expect(updatedNodes[0].data._loopIndex).toBe(5)
- expect(updatedNodes[1].data._waitingRun).toBe(true)
- expect(updatedNodes[1].data._runningStatus).toBe(NodeRunningStatus.Waiting)
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._loopIndex).toBe(5)
+ expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._waitingRun).toBe(true)
+ expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Waiting)
+ })
})
})
describe('useWorkflowNodeLoopFinished', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n0', target: 'n1', data: {} },
- ]
- })
-
- it('should update tracing, node status and edges', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopFinished(), {
+ it('should update tracing, node status and edges', async () => {
+ const { result, store } = renderRunEventHook(() => useWorkflowNodeLoopFinished(), {
+ nodes: [
+ createNode({
+ id: 'n1',
+ data: { _runningStatus: NodeRunningStatus.Running },
+ }),
+ ],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }],
@@ -258,12 +319,18 @@ describe('useWorkflowNodeLoopFinished', () => {
},
})
- result.current.handleWorkflowNodeLoopFinished({
- data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
- } as LoopFinishedResponse)
+ act(() => {
+ result.current.handleWorkflowNodeLoopFinished({
+ data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
+ } as LoopFinishedResponse)
+ })
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
- expect(rfState.setEdges).toHaveBeenCalledOnce()
+
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
+ expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
+ })
})
})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts
index 51d1ba5b74..73b16acf2e 100644
--- a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts
@@ -4,8 +4,10 @@ import type {
LoopStartedResponse,
NodeStartedResponse,
} from '@/types/workflow'
-import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
-import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
+import { act, waitFor } from '@testing-library/react'
+import { useEdges, useNodes, useStoreApi } from 'reactflow'
+import { createEdge, createNode } from '../../__tests__/fixtures'
+import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus } from '../../types'
import { useWorkflowNodeHumanInputRequired } from '../use-workflow-run-event/use-workflow-node-human-input-required'
@@ -13,67 +15,145 @@ import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-w
import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started'
import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started'
-vi.mock('reactflow', async () =>
- (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
-
-function findNodeById(nodes: Array<{ id: string, data: Record }>, id: string) {
- return nodes.find(n => n.id === id)!
+type NodeRuntimeState = {
+ _waitingRun?: boolean
+ _runningStatus?: NodeRunningStatus
+ _iterationLength?: number
+ _loopLength?: number
}
+type EdgeRuntimeState = {
+ _sourceRunningStatus?: NodeRunningStatus
+ _targetRunningStatus?: NodeRunningStatus
+ _waitingRun?: boolean
+}
+
+const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
+ (node?.data ?? {}) as NodeRuntimeState
+
+const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
+ (edge?.data ?? {}) as EdgeRuntimeState
+
const containerParams = { clientWidth: 1200, clientHeight: 800 }
-describe('useWorkflowNodeStarted', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
- { id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
- { id: 'n2', position: { x: 400, y: 50 }, width: 200, height: 80, parentId: 'n1', data: { _waitingRun: true } },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n0', target: 'n1', data: {} },
- ]
- })
+function createViewportNodes() {
+ return [
+ createNode({
+ id: 'n0',
+ width: 200,
+ height: 80,
+ data: { _runningStatus: NodeRunningStatus.Succeeded },
+ }),
+ createNode({
+ id: 'n1',
+ position: { x: 100, y: 50 },
+ width: 200,
+ height: 80,
+ data: { _waitingRun: true },
+ }),
+ createNode({
+ id: 'n2',
+ position: { x: 400, y: 50 },
+ width: 200,
+ height: 80,
+ parentId: 'n1',
+ data: { _waitingRun: true },
+ }),
+ ]
+}
- it('should push to tracing, set node running, and adjust viewport for root node', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
+function createViewportEdges() {
+ return [
+ createEdge({
+ id: 'e1',
+ source: 'n0',
+ target: 'n1',
+ sourceHandle: 'source',
+ data: {},
+ }),
+ ]
+}
+
+function renderViewportHook>(
+ useHook: () => T,
+ options?: {
+ nodes?: ReturnType
+ edges?: ReturnType
+ initialStoreState?: Record
+ },
+) {
+ const {
+ nodes = createViewportNodes(),
+ edges = createViewportEdges(),
+ initialStoreState,
+ } = options ?? {}
+
+ return renderWorkflowFlowHook(() => ({
+ ...useHook(),
+ nodes: useNodes(),
+ edges: useEdges(),
+ reactFlowStore: useStoreApi(),
+ }), {
+ nodes,
+ edges,
+ reactFlowProps: { fitView: false },
+ initialStoreState,
+ })
+}
+
+describe('useWorkflowNodeStarted', () => {
+ it('should push to tracing, set node running, and adjust viewport for root node', async () => {
+ const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
- result.current.handleWorkflowNodeStarted(
- { data: { node_id: 'n1' } } as NodeStartedResponse,
- containerParams,
- )
+ act(() => {
+ result.current.handleWorkflowNodeStarted(
+ { data: { node_id: 'n1' } } as NodeStartedResponse,
+ containerParams,
+ )
+ })
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(1)
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
- expect(rfState.setViewport).toHaveBeenCalledOnce()
+ await waitFor(() => {
+ const transform = result.current.reactFlowStore.getState().transform
+ expect(transform[0]).toBe(200)
+ expect(transform[1]).toBe(310)
+ expect(transform[2]).toBe(1)
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- const n1 = findNodeById(updatedNodes, 'n1')
- expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
- expect(n1.data._waitingRun).toBe(false)
-
- expect(rfState.setEdges).toHaveBeenCalledOnce()
+ const node = result.current.nodes.find(item => item.id === 'n1')
+ expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
+ expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
+ expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
+ })
})
- it('should not adjust viewport for child node (has parentId)', () => {
- const { result } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
+ it('should not adjust viewport for child node (has parentId)', async () => {
+ const { result } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
- result.current.handleWorkflowNodeStarted(
- { data: { node_id: 'n2' } } as NodeStartedResponse,
- containerParams,
- )
+ act(() => {
+ result.current.handleWorkflowNodeStarted(
+ { data: { node_id: 'n2' } } as NodeStartedResponse,
+ containerParams,
+ )
+ })
- expect(rfState.setViewport).not.toHaveBeenCalled()
+ await waitFor(() => {
+ const transform = result.current.reactFlowStore.getState().transform
+ expect(transform[0]).toBe(0)
+ expect(transform[1]).toBe(0)
+ expect(transform[2]).toBe(1)
+ expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Running)
+ })
})
it('should update existing tracing entry if node_id exists at non-zero index', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
+ const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
@@ -84,10 +164,12 @@ describe('useWorkflowNodeStarted', () => {
},
})
- result.current.handleWorkflowNodeStarted(
- { data: { node_id: 'n1' } } as NodeStartedResponse,
- containerParams,
- )
+ act(() => {
+ result.current.handleWorkflowNodeStarted(
+ { data: { node_id: 'n1' } } as NodeStartedResponse,
+ containerParams,
+ )
+ })
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(2)
@@ -96,92 +178,80 @@ describe('useWorkflowNodeStarted', () => {
})
describe('useWorkflowNodeIterationStarted', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
- { id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n0', target: 'n1', data: {} },
- ]
- })
-
- it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationStarted(), {
+ it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', async () => {
+ const { result, store } = renderViewportHook(() => useWorkflowNodeIterationStarted(), {
+ nodes: createViewportNodes().slice(0, 2),
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 99,
},
})
- result.current.handleWorkflowNodeIterationStarted(
- { data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
- containerParams,
- )
+ act(() => {
+ result.current.handleWorkflowNodeIterationStarted(
+ { data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
+ containerParams,
+ )
+ })
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
-
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
- expect(rfState.setViewport).toHaveBeenCalledOnce()
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- const n1 = findNodeById(updatedNodes, 'n1')
- expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
- expect(n1.data._iterationLength).toBe(10)
- expect(n1.data._waitingRun).toBe(false)
+ await waitFor(() => {
+ const transform = result.current.reactFlowStore.getState().transform
+ expect(transform[0]).toBe(200)
+ expect(transform[1]).toBe(310)
+ expect(transform[2]).toBe(1)
- expect(rfState.setEdges).toHaveBeenCalledOnce()
+ const node = result.current.nodes.find(item => item.id === 'n1')
+ expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
+ expect(getNodeRuntimeState(node)._iterationLength).toBe(10)
+ expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
+ expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
+ })
})
})
describe('useWorkflowNodeLoopStarted', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
- { id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
- ]
- rfState.edges = [
- { id: 'e1', source: 'n0', target: 'n1', data: {} },
- ]
- })
-
- it('should push to tracing, set viewport, and update node with _loopLength', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopStarted(), {
+ it('should push to tracing, set viewport, and update node with _loopLength', async () => {
+ const { result, store } = renderViewportHook(() => useWorkflowNodeLoopStarted(), {
+ nodes: createViewportNodes().slice(0, 2),
initialStoreState: { workflowRunningData: baseRunningData() },
})
- result.current.handleWorkflowNodeLoopStarted(
- { data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
- containerParams,
- )
+ act(() => {
+ result.current.handleWorkflowNodeLoopStarted(
+ { data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
+ containerParams,
+ )
+ })
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running)
- expect(rfState.setViewport).toHaveBeenCalledOnce()
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- const n1 = findNodeById(updatedNodes, 'n1')
- expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
- expect(n1.data._loopLength).toBe(5)
- expect(n1.data._waitingRun).toBe(false)
+ await waitFor(() => {
+ const transform = result.current.reactFlowStore.getState().transform
+ expect(transform[0]).toBe(200)
+ expect(transform[1]).toBe(310)
+ expect(transform[2]).toBe(1)
- expect(rfState.setEdges).toHaveBeenCalledOnce()
+ const node = result.current.nodes.find(item => item.id === 'n1')
+ expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
+ expect(getNodeRuntimeState(node)._loopLength).toBe(5)
+ expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
+ expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
+ })
})
})
describe('useWorkflowNodeHumanInputRequired', () => {
- beforeEach(() => {
- resetReactFlowMockState()
- rfState.nodes = [
- { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
- { id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
- ]
- })
-
- it('should create humanInputFormDataList and set tracing/node to Paused', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
+ it('should create humanInputFormDataList and set tracing/node to Paused', async () => {
+ const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
+ nodes: [
+ createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
+ createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
+ ],
+ edges: [],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
@@ -189,21 +259,29 @@ describe('useWorkflowNodeHumanInputRequired', () => {
},
})
- result.current.handleWorkflowNodeHumanInputRequired({
- data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
- } as HumanInputRequiredResponse)
+ act(() => {
+ result.current.handleWorkflowNodeHumanInputRequired({
+ data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
+ } as HumanInputRequiredResponse)
+ })
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(1)
expect(state.humanInputFormDataList![0].form_id).toBe('f1')
expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused)
- const updatedNodes = rfState.setNodes.mock.calls[0][0]
- expect(findNodeById(updatedNodes, 'n1').data._runningStatus).toBe(NodeRunningStatus.Paused)
+ await waitFor(() => {
+ expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n1'))._runningStatus).toBe(NodeRunningStatus.Paused)
+ })
})
it('should update existing form entry for same node_id', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
+ const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
+ nodes: [
+ createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
+ createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
+ ],
+ edges: [],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
@@ -214,9 +292,11 @@ describe('useWorkflowNodeHumanInputRequired', () => {
},
})
- result.current.handleWorkflowNodeHumanInputRequired({
- data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
- } as HumanInputRequiredResponse)
+ act(() => {
+ result.current.handleWorkflowNodeHumanInputRequired({
+ data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
+ } as HumanInputRequiredResponse)
+ })
const formList = store.getState().workflowRunningData!.humanInputFormDataList!
expect(formList).toHaveLength(1)
@@ -224,7 +304,12 @@ describe('useWorkflowNodeHumanInputRequired', () => {
})
it('should append new form entry for different node_id', () => {
- const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
+ const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
+ nodes: [
+ createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
+ createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
+ ],
+ edges: [],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }],
@@ -235,9 +320,11 @@ describe('useWorkflowNodeHumanInputRequired', () => {
},
})
- result.current.handleWorkflowNodeHumanInputRequired({
- data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
- } as HumanInputRequiredResponse)
+ act(() => {
+ result.current.handleWorkflowNodeHumanInputRequired({
+ data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
+ } as HumanInputRequiredResponse)
+ })
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts
index 24cc9455cb..7b1d328dcf 100644
--- a/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts
@@ -1,6 +1,6 @@
import { act, renderHook } from '@testing-library/react'
-import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
-import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
+import { createNode } from '../../__tests__/fixtures'
+import { baseRunningData, renderWorkflowFlowHook, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import {
useIsChatMode,
@@ -10,9 +10,6 @@ import {
useWorkflowReadOnly,
} from '../use-workflow'
-vi.mock('reactflow', async () =>
- (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
-
let mockAppMode = 'workflow'
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { mode: string } }) => unknown) => selector({ appDetail: { mode: mockAppMode } }),
@@ -20,7 +17,6 @@ vi.mock('@/app/components/app/store', () => ({
beforeEach(() => {
vi.clearAllMocks()
- resetReactFlowMockState()
mockAppMode = 'workflow'
})
@@ -158,37 +154,50 @@ describe('useNodesReadOnly', () => {
// ---------------------------------------------------------------------------
describe('useIsNodeInIteration', () => {
- beforeEach(() => {
- rfState.nodes = [
- { id: 'iter-1', position: { x: 0, y: 0 }, data: { type: 'iteration' } },
- { id: 'child-1', position: { x: 10, y: 0 }, parentId: 'iter-1', data: {} },
- { id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
- { id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
- ]
- })
+ const createIterationNodes = () => [
+ createNode({ id: 'iter-1' }),
+ createNode({ id: 'child-1', parentId: 'iter-1' }),
+ createNode({ id: 'grandchild-1', parentId: 'child-1' }),
+ createNode({ id: 'outside-1' }),
+ ]
it('should return true when node is a direct child of the iteration', () => {
- const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
+ nodes: createIterationNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInIteration('child-1')).toBe(true)
})
it('should return false for a grandchild (only checks direct parentId)', () => {
- const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
+ nodes: createIterationNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInIteration('grandchild-1')).toBe(false)
})
it('should return false when node is outside the iteration', () => {
- const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
+ nodes: createIterationNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInIteration('outside-1')).toBe(false)
})
it('should return false when node does not exist', () => {
- const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
+ nodes: createIterationNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInIteration('nonexistent')).toBe(false)
})
it('should return false when iteration id has no children', () => {
- const { result } = renderHook(() => useIsNodeInIteration('no-such-iter'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('no-such-iter'), {
+ nodes: createIterationNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInIteration('child-1')).toBe(false)
})
})
@@ -198,37 +207,50 @@ describe('useIsNodeInIteration', () => {
// ---------------------------------------------------------------------------
describe('useIsNodeInLoop', () => {
- beforeEach(() => {
- rfState.nodes = [
- { id: 'loop-1', position: { x: 0, y: 0 }, data: { type: 'loop' } },
- { id: 'child-1', position: { x: 10, y: 0 }, parentId: 'loop-1', data: {} },
- { id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
- { id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
- ]
- })
+ const createLoopNodes = () => [
+ createNode({ id: 'loop-1' }),
+ createNode({ id: 'child-1', parentId: 'loop-1' }),
+ createNode({ id: 'grandchild-1', parentId: 'child-1' }),
+ createNode({ id: 'outside-1' }),
+ ]
it('should return true when node is a direct child of the loop', () => {
- const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
+ nodes: createLoopNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInLoop('child-1')).toBe(true)
})
it('should return false for a grandchild (only checks direct parentId)', () => {
- const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
+ nodes: createLoopNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInLoop('grandchild-1')).toBe(false)
})
it('should return false when node is outside the loop', () => {
- const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
+ nodes: createLoopNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInLoop('outside-1')).toBe(false)
})
it('should return false when node does not exist', () => {
- const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
+ nodes: createLoopNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInLoop('nonexistent')).toBe(false)
})
it('should return false when loop id has no children', () => {
- const { result } = renderHook(() => useIsNodeInLoop('no-such-loop'))
+ const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('no-such-loop'), {
+ nodes: createLoopNodes(),
+ edges: [],
+ })
expect(result.current.isNodeInLoop('child-1')).toBe(false)
})
})
diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx
index 72e2032d75..c3738ca260 100644
--- a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx
+++ b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx
@@ -6,16 +6,18 @@ import type {
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { createDocLinkMock } from '../../../../__tests__/i18n'
import { AgentStrategy } from '../agent-strategy'
const createI18nLabel = (text: string) => ({ en_US: text, zh_Hans: text })
+const mockDocLink = createDocLinkMock('/docs')
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: () => ({ data: null }),
}))
vi.mock('@/context/i18n', () => ({
- useDocLink: () => () => '/docs',
+ useDocLink: () => mockDocLink,
}))
vi.mock('@/hooks/use-i18n', () => ({
diff --git a/web/app/components/workflow/nodes/_base/components/field.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx
similarity index 98%
rename from web/app/components/workflow/nodes/_base/components/field.spec.tsx
rename to web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx
index a34a862118..e16ed108f7 100644
--- a/web/app/components/workflow/nodes/_base/components/field.spec.tsx
+++ b/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
-import Field from './field'
+import Field from '../field'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => {popupContent}
,
diff --git a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx
similarity index 86%
rename from web/app/components/workflow/nodes/_base/components/node-control.spec.tsx
rename to web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx
index 1843f77a52..82650a61f4 100644
--- a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx
+++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx
@@ -1,8 +1,8 @@
-import type { CommonNodeType } from '../../../types'
+import type { CommonNodeType } from '../../../../types'
import { fireEvent, screen } from '@testing-library/react'
-import { renderWorkflowComponent } from '../../../__tests__/workflow-test-env'
-import { BlockEnum, NodeRunningStatus } from '../../../types'
-import NodeControl from './node-control'
+import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env'
+import { BlockEnum, NodeRunningStatus } from '../../../../types'
+import NodeControl from '../node-control'
const {
mockHandleNodeSelect,
@@ -14,8 +14,8 @@ const {
let mockPluginInstallLocked = false
-vi.mock('../../../hooks', async () => {
- const actual = await vi.importActual('../../../hooks')
+vi.mock('../../../../hooks', async () => {
+ const actual = await vi.importActual('../../../../hooks')
return {
...actual,
useNodesInteractions: () => ({
@@ -24,15 +24,15 @@ vi.mock('../../../hooks', async () => {
}
})
-vi.mock('../../../utils', async () => {
- const actual = await vi.importActual('../../../utils')
+vi.mock('../../../../utils', async () => {
+ const actual = await vi.importActual('../../../../utils')
return {
...actual,
canRunBySingle: mockCanRunBySingle,
}
})
-vi.mock('./panel-operator', () => ({
+vi.mock('../panel-operator', () => ({
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
<>
onOpenChange(true)}>open panel
diff --git a/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..f66c5f0473
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/collapse/__tests__/index.spec.tsx
@@ -0,0 +1,83 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import Collapse from '../index'
+
+describe('Collapse', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Collapse should toggle local state when interactive and stay fixed when disabled.
+ describe('Interaction', () => {
+ it('should expand collapsed content and notify onCollapse when clicked', async () => {
+ const user = userEvent.setup()
+ const onCollapse = vi.fn()
+
+ render(
+ Advanced