import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import { renderWithNuqs } from '@/test/nuqs-testing' import IntegrationsPage from '../page' const { mockRouterPush, mockWindowOpen } = vi.hoisted(() => ({ mockRouterPush: vi.fn(), mockWindowOpen: vi.fn(), })) const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: ['tool.manage', 'mcp.manage'] as string[], })) const { mockCanManagement, mockCanDebugger, mockCanSetPermissions, mockIsPermissionLoading, mockReferenceSetting, mockSetReferenceSettings, } = vi.hoisted(() => ({ mockCanManagement: vi.fn(() => true), mockCanDebugger: vi.fn(() => true), mockCanSetPermissions: vi.fn(() => true), mockIsPermissionLoading: vi.fn(() => false), mockReferenceSetting: vi.fn(() => ({ permission: { install_permission: 'everyone', debug_permission: 'admins', }, auto_upgrade: { strategy_setting: 'fix_only', upgrade_time_of_day: 0, upgrade_mode: 'all', exclude_plugins: [], include_plugins: [], }, })), mockSetReferenceSettings: vi.fn(), })) vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush, }), })) vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ usePluginSettingsAccess: () => ({ permission: mockReferenceSetting().permission, canInstallPlugin: mockCanManagement(), canDeletePlugin: true, canManagement: mockCanManagement(), canDebugger: mockCanDebugger(), canSetPermissions: mockCanSetPermissions(), canSetPluginPreferences: mockCanSetPermissions(), canUpdatePlugin: true, isPermissionLoading: mockIsPermissionLoading(), setPluginPermissionSettings: mockSetReferenceSettings, }), default: () => ({ referenceSetting: mockReferenceSetting(), canInstallPlugin: mockCanManagement(), canDeletePlugin: true, canManagement: mockCanManagement(), canDebugger: mockCanDebugger(), canSetPermissions: mockCanSetPermissions(), canSetPluginPreferences: mockCanSetPermissions(), canUpdatePlugin: true, isPermissionLoading: mockIsPermissionLoading(), setReferenceSettings: mockSetReferenceSettings, }), })) vi.mock('@/app/components/plugins/plugin-page/debug-info', () => ({ __esModule: true, default: ({ triggerClassName, triggerContent, }: { triggerClassName?: string triggerContent?: React.ReactNode }) => ( ), })) vi.mock('@/app/components/plugins/reference-setting-modal', () => ({ __esModule: true, default: ({ onHide }: { onHide: () => void }) => (
), })) vi.mock('@/app/components/header/account-setting/update-setting-dialog', () => ({ default: () => ( ), })) vi.mock('@/app/components/plugins/plugin-page/install-plugin-dropdown', () => ({ __esModule: true, default: ({ disabled, onSwitchToMarketplaceTab, showTriggerArrow, triggerClassName, }: { disabled?: boolean onSwitchToMarketplaceTab: () => void showTriggerArrow?: boolean triggerClassName?: string }) => ( ), })) vi.mock('@/app/components/plugins/plugin-page/plugin-tasks', () => ({ __esModule: true, default: () => ( ), })) vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace-query', () => ({ __esModule: true, default: ({ installContextCategory }: { installContextCategory?: string }) => (
), })) vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({ __esModule: true, default: ({ layout, onOpenMarketplace, onSearchTextChange, searchText, }: { layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode onOpenMarketplace?: () => void onSearchTextChange?: (value: string) => void searchText: string }) => { const toolbar = (
onSearchTextChange?.(event.target.value)} />
) const body = (
) if (layout) return layout({ body, toolbar }) return (
onSearchTextChange?.(event.target.value)} />
) }, })) vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({ __esModule: true, default: ({ layout, onOpenMarketplace, }: { layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode onOpenMarketplace?: () => void }) => { const toolbar =
const body = (
) if (layout) return layout({ body, toolbar }) return body }, })) vi.mock('@/app/components/header/account-setting/api-based-extension-page', () => ({ __esModule: true, ApiBasedExtensionPage: ({ layout, }: { layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode }) => { const toolbar =
const body =
if (layout) return layout({ body, toolbar }) return body }, })) vi.mock('../tool-provider-list', async () => { const { useState } = await vi.importActual('react') const MockProviderList = ({ category, layout, }: { category?: string layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode }) => { const [mountedCategory] = useState(category) const toolbar =
const body = (
{category}
) if (layout) return layout({ body, toolbar }) return body } return { __esModule: true, default: MockProviderList, } }) vi.mock('../plugin-category-page', () => ({ __esModule: true, default: ({ canInstall, category, layout, onSwitchToMarketplace, toolbarAction, }: { canInstall?: boolean category: string layout?: (parts: { body: React.ReactNode; toolbar: React.ReactNode }) => React.ReactNode onSwitchToMarketplace?: () => void toolbarAction?: React.ReactNode }) => { const toolbar =
{toolbarAction}
const body = (
) if (layout) return layout({ body, toolbar }) return ( <> {toolbar} {body} ) }, })) const renderIntegrationsPage = ( searchParams?: Record, sectionOrProps?: | React.ComponentProps['section'] | Partial>, ) => { const props = typeof sectionOrProps === 'string' ? { section: sectionOrProps } : sectionOrProps return renderWithNuqs(, { searchParams }) } describe('IntegrationsPage', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('open', mockWindowOpen) mockCanManagement.mockReturnValue(true) mockCanDebugger.mockReturnValue(true) mockCanSetPermissions.mockReturnValue(true) mockIsPermissionLoading.mockReturnValue(false) mockConsoleState.workspacePermissionKeys = ['tool.manage', 'mcp.manage'] mockReferenceSetting.mockReturnValue({ permission: { install_permission: 'everyone', debug_permission: 'admins', }, auto_upgrade: { strategy_setting: 'fix_only', upgrade_time_of_day: 0, upgrade_mode: 'all', exclude_plugins: [], include_plugins: [], }, }) }) it('defaults to the model provider section when no query is provided', () => { const { container } = renderIntegrationsPage() expect(screen.getByTestId('model-provider-page')).toBeInTheDocument() expect(screen.getAllByText('common.settings.provider')).toHaveLength(2) expect(container.firstElementChild).toHaveClass('bg-components-panel-bg') expect(container.querySelector('aside')).toHaveClass('bg-components-panel-bg') }) it('renders the model provider section from the section query', () => { renderIntegrationsPage({ section: 'provider' }) expect(screen.getByTestId('model-provider-page')).toBeInTheDocument() expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute( 'data-install-context-category', 'model', ) expect( screen.getByTestId('model-provider-toolbar').closest('[class*="max-w-[1600px]"]'), ).toHaveClass('px-6', 'pt-3', 'pb-2') expect( within(screen.getByTestId('model-provider-toolbar').closest('section')!).getByText( 'common.settings.provider', ), ).toHaveClass('title-2xl-semi-bold') expect(screen.getByTestId('model-provider-page').parentElement).toHaveClass( 'max-w-[1600px]', 'px-6', ) expect(screen.getByTestId('model-provider-page').parentElement).not.toHaveClass('pt-2') expect(screen.getAllByText('common.settings.provider')).toHaveLength(2) expect(screen.getByRole('link', { name: 'common.settings.provider' })).toHaveAttribute( 'aria-current', 'page', ) expect(screen.getByRole('link', { name: 'common.settings.dataSource' })).not.toHaveAttribute( 'aria-current', ) expect(screen.getByRole('textbox', { name: 'search' })).toBeInTheDocument() }) it('orders sidebar items to match the integrations setting menu', () => { renderIntegrationsPage({ section: 'provider' }) const navText = screen.getByRole('navigation').textContent ?? '' expect(navText.indexOf('common.settings.provider')).toBeLessThan( navText.indexOf('common.menus.tools'), ) expect(navText.indexOf('common.menus.tools')).toBeLessThan( navText.indexOf('common.settings.dataSource'), ) expect(navText.indexOf('common.settings.dataSource')).toBeLessThan( navText.indexOf('plugin.categorySingle.trigger'), ) expect(navText.indexOf('plugin.categorySingle.trigger')).toBeLessThan( navText.indexOf('plugin.categorySingle.agent'), ) expect(navText.indexOf('plugin.categorySingle.agent')).toBeLessThan( navText.indexOf('plugin.categorySingle.extension'), ) expect(navText.indexOf('plugin.categorySingle.extension')).toBeLessThan( navText.indexOf('common.settings.customEndpoint'), ) }) it('anchors step-by-step tour targets inside stable sidebar rows', () => { renderIntegrationsPage({ section: 'mcp' }) const targetRows = [ { label: 'common.settings.provider', target: STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav, }, { label: 'common.toolsPage.toolPlugin', target: STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav, }, { label: 'MCP', target: STEP_BY_STEP_TOUR_TARGETS.integrationMcpNav, }, { label: 'common.settings.dataSource', target: STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceNav, }, { label: 'plugin.categorySingle.trigger', target: STEP_BY_STEP_TOUR_TARGETS.integrationTriggerNav, }, ] targetRows.forEach(({ label, target }) => { const row = screen.getByRole('link', { name: label }) const targetAnchor = row.querySelector(`[data-step-by-step-tour-target="${target}"]`) expect(row).not.toHaveAttribute('data-step-by-step-tour-target') expect(row).toHaveClass('relative') expect(targetAnchor).toBeInTheDocument() expect(targetAnchor).toHaveClass('absolute', 'inset-y-1', 'left-0', 'right-0') }) }) it('keeps sidebar item icons outlined when the item is active', () => { const providerView = renderIntegrationsPage({ section: 'provider' }) expect( screen .getByRole('link', { name: 'common.settings.dataSource' }) .querySelector('.i-ri-database-2-line'), ).toBeInTheDocument() providerView.rerender() expect( screen .getByRole('link', { name: 'common.settings.dataSource' }) .querySelector('.i-ri-database-2-line'), ).toBeInTheDocument() expect( screen .getByRole('link', { name: 'common.settings.dataSource' }) .querySelector('.i-ri-database-2-fill'), ).not.toBeInTheDocument() }) it('renders plugin category sections from the section query', () => { const toolView = renderIntegrationsPage({ section: 'builtin' }) expect(screen.getByTestId('plugin-category-tool')).toBeInTheDocument() expect(screen.getByTestId('plugin-category-tool').parentElement).toHaveClass( 'flex', 'flex-col', 'overflow-hidden', ) expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute( 'href', '/integrations/tools/built-in', ) toolView.unmount() const triggerView = renderIntegrationsPage({ section: 'trigger' }) expect(screen.getByTestId('plugin-category-trigger')).toBeInTheDocument() expect(screen.getByTestId('plugin-category-trigger').parentElement).toHaveClass( 'flex', 'flex-col', 'overflow-hidden', ) expect(screen.getByRole('link', { name: 'plugin.categorySingle.trigger' })).toHaveAttribute( 'href', '/integrations/trigger', ) triggerView.unmount() const agentStrategyView = renderIntegrationsPage({ section: 'agent-strategy' }) expect(screen.getByTestId('plugin-category-agent-strategy')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'plugin.categorySingle.agent' })).toHaveAttribute( 'href', '/integrations/agent-strategy', ) agentStrategyView.unmount() renderIntegrationsPage({ section: 'extension' }) expect(screen.getByTestId('plugin-category-extension')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'plugin.categorySingle.extension' })).toHaveAttribute( 'href', '/integrations/extension', ) }) it.each([ ['provider', 'model provider marketplace', '/plugins/model'], ['data-source', 'data source marketplace', '/plugins/datasource'], ['extension', 'empty marketplace', '/plugins/extension'], ] as const)( 'opens the %s marketplace path from integrations', async (section, buttonName, marketplacePath) => { const user = userEvent.setup() renderIntegrationsPage({ section }) await user.click(screen.getByRole('button', { name: buttonName })) expect(mockWindowOpen).toHaveBeenCalledWith( expect.stringContaining(`${marketplacePath}?source=`), '_blank', 'noopener,noreferrer', ) expect(mockRouterPush).not.toHaveBeenCalled() }, ) it('passes marketplace platform paths to external marketplace callbacks', async () => { const user = userEvent.setup() const onSwitchToMarketplace = vi.fn() renderIntegrationsPage({ section: 'trigger' }, { onSwitchToMarketplace }) await user.click(screen.getByRole('button', { name: 'empty marketplace' })) expect(onSwitchToMarketplace).toHaveBeenCalledWith('/plugins/trigger') expect(mockRouterPush).not.toHaveBeenCalled() }) it('renders migrated legacy setting sections', () => { const { unmount } = renderIntegrationsPage({ section: 'data-source' }) expect(screen.getByTestId('data-source-page')).toBeInTheDocument() expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute( 'data-install-context-category', 'datasource', ) expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveTextContent( 'plugin.debugInfo.title', ) unmount() renderIntegrationsPage({ section: 'custom-endpoint' }) expect(screen.getByTestId('api-extension-page')).toBeInTheDocument() expect(screen.queryByText('plugin.autoUpdate.autoUpdate')).not.toBeInTheDocument() }) it('hides the plugin debug action when debug permission is unavailable', () => { mockCanDebugger.mockReturnValue(false) renderIntegrationsPage({ section: 'data-source' }) expect( screen.queryByLabelText('plugin.privilege.noDebugPermissionTooltip'), ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'plugin debug' })).not.toBeInTheDocument() }) it('renders existing pages from route sections', () => { const modelProviderView = renderIntegrationsPage(undefined, 'provider') expect(screen.getByTestId('model-provider-page')).toBeInTheDocument() modelProviderView.unmount() const mcpView = renderIntegrationsPage(undefined, 'mcp') expect(screen.getByTestId('tool-provider-list')).toHaveTextContent('mcp') expect(screen.getByTestId('tool-provider-list').parentElement).toHaveClass( 'flex', 'flex-col', 'overflow-hidden', ) mcpView.unmount() renderIntegrationsPage(undefined, 'data-source') expect(screen.getByTestId('data-source-page')).toBeInTheDocument() }) it('renders the MCP route as read-only without mcp.manage', () => { mockConsoleState.workspacePermissionKeys = ['tool.manage'] renderIntegrationsPage(undefined, 'mcp') expect(screen.getByTestId('tool-provider-list')).toHaveTextContent('mcp') }) it.each(['custom-tool', 'workflow-tool'] as const)( 'renders the %s route as read-only without tool.manage', (section) => { mockConsoleState.workspacePermissionKeys = ['mcp.manage'] renderIntegrationsPage(undefined, section) expect(screen.getByTestId('tool-provider-list')).toBeInTheDocument() }, ) it('remounts the tools section content when the route section changes', () => { const view = renderIntegrationsPage(undefined, 'builtin') expect(screen.getByTestId('plugin-category-tool')).toBeInTheDocument() view.rerender() expect(screen.getByTestId('tool-provider-list')).toHaveAttribute('data-mounted-category', 'mcp') }) it('keeps existing category-only tools URLs functional', () => { renderIntegrationsPage({ category: 'mcp' }) expect(screen.getByTestId('tool-provider-list')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.menus.tools' })).not.toHaveClass( 'bg-state-base-active', ) expect(screen.getByRole('button', { name: 'common.menus.tools' })).not.toHaveAttribute( 'aria-current', ) expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveAttribute( 'href', '/integrations/tools/built-in', ) expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toHaveClass('pl-8') expect( screen .getByRole('link', { name: 'common.toolsPage.toolPlugin' }) .querySelector('.i-custom-vender-integrations-tools'), ).toBeInTheDocument() expect(screen.getByRole('link', { name: 'MCP' })).toHaveAttribute( 'href', '/integrations/tools/mcp', ) expect(screen.getByRole('link', { name: 'MCP' })).toHaveClass('bg-state-base-active') expect(screen.getByRole('link', { name: 'MCP' })).toHaveAttribute('aria-current', 'page') expect(screen.getByRole('link', { name: 'common.settings.swaggerAPIAsTool' })).toHaveAttribute( 'href', '/integrations/tools/api', ) expect(screen.getByRole('link', { name: 'workflow.common.workflowAsTool' })).toHaveAttribute( 'href', '/integrations/tools/workflow', ) const workflowToolIcon = screen .getByRole('link', { name: 'workflow.common.workflowAsTool' }) .querySelector('.i-custom-vender-integrations-workflow-as-tool') expect(workflowToolIcon).toBeInTheDocument() expect(workflowToolIcon).toHaveClass('size-4') expect( screen .getByRole('link', { name: 'workflow.common.workflowAsTool' }) .querySelector('.i-ri-node-tree'), ).not.toBeInTheDocument() expect( screen .getByRole('link', { name: 'workflow.common.workflowAsTool' }) .compareDocumentPosition( screen.getByRole('link', { name: 'common.settings.swaggerAPIAsTool' }), ), ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) }) it('toggles the tools submenu from the keyboard without other nav items closing it', async () => { const user = userEvent.setup() const onSectionChange = vi.fn() renderWithNuqs() expect(screen.getByRole('button', { name: 'common.settings.provider' })).toHaveClass( 'bg-state-base-active', ) const toolsButton = screen.getByRole('button', { name: 'common.menus.tools' }) expect(toolsButton).toHaveAttribute('aria-expanded', 'false') expect(screen.queryByRole('button', { name: 'MCP' })).not.toBeInTheDocument() toolsButton.focus() await user.keyboard('{Enter}') expect(onSectionChange).toHaveBeenCalledWith('builtin') expect(toolsButton).toHaveAttribute('aria-expanded', 'true') expect(screen.getByRole('button', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'common.settings.provider' })) expect(onSectionChange).toHaveBeenCalledWith('provider') expect(toolsButton).toHaveAttribute('aria-expanded', 'true') expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument() toolsButton.focus() await user.keyboard(' ') expect(toolsButton).toHaveAttribute('aria-expanded', 'false') expect(screen.queryByRole('button', { name: 'MCP' })).not.toBeInTheDocument() expect(onSectionChange).toHaveBeenCalledTimes(2) }) it('keeps custom, workflow, and MCP tool entries visible without manage permissions', async () => { const user = userEvent.setup() mockConsoleState.workspacePermissionKeys = ['mcp.manage'] renderIntegrationsPage(undefined, { section: 'provider', onSectionChange: vi.fn() }) await user.click(screen.getByRole('button', { name: 'common.menus.tools' })) expect(screen.getByRole('button', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'MCP' })).toBeInTheDocument() expect( screen.getByRole('button', { name: 'workflow.common.workflowAsTool' }), ).toBeInTheDocument() expect( screen.getByRole('button', { name: 'common.settings.swaggerAPIAsTool' }), ).toBeInTheDocument() }) it('opens tools to the tools plugin page when the parent tools nav is clicked', async () => { const user = userEvent.setup() renderIntegrationsPage(undefined, 'provider') await user.click(screen.getByRole('button', { name: 'common.menus.tools' })) expect(mockRouterPush).toHaveBeenCalledWith('/integrations/tools/built-in') }) it('keeps the tools disclosure independent from route section changes', async () => { const user = userEvent.setup() const view = renderIntegrationsPage(undefined, 'mcp') expect(screen.getByTestId('tool-provider-list')).toHaveAttribute('data-mounted-category', 'mcp') expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute( 'aria-expanded', 'true', ) expect(screen.getByRole('link', { name: 'common.toolsPage.toolPlugin' })).toBeInTheDocument() expect(screen.getByRole('link', { name: 'MCP' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'common.menus.tools' })) expect(screen.getByTestId('tool-provider-list')).toHaveAttribute('data-mounted-category', 'mcp') expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute( 'aria-expanded', 'false', ) expect( screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' }), ).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: 'MCP' })).not.toBeInTheDocument() view.rerender() expect(screen.getByTestId('model-provider-page')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute( 'aria-expanded', 'false', ) expect( screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' }), ).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: 'MCP' })).not.toBeInTheDocument() view.rerender() await waitFor(() => { expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute( 'aria-expanded', 'true', ) }) expect(screen.getByRole('link', { name: 'MCP' })).toHaveClass('bg-state-base-active') }) it('renders the tools header for tool sections', () => { renderIntegrationsPage({ section: 'builtin' }) expect(screen.getAllByText('common.toolsPage.toolPlugin')).toHaveLength(2) expect(screen.getByText('common.toolsPage.description')).toBeInTheDocument() expect( screen.getByText('common.toolsPage.description').closest('[class*="max-w-[1600px]"]'), ).toHaveClass('px-6') expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( 'href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools', ) }) it('aligns model provider headers to the unified content frame', () => { renderIntegrationsPage({ section: 'provider' }) const description = screen.getByText('common.modelProvider.pageDesc') expect(description.closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6') expect( screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i }), ).toBeInTheDocument() }) it('aligns plugin category headers to the unified content frame', () => { renderIntegrationsPage({ section: 'trigger' }) expect( screen.getByText('common.triggerPage.description').closest('[class*="max-w-[1600px]"]'), ).toHaveClass('px-6') }) it('renders the mcp header for the mcp section', () => { renderIntegrationsPage({ section: 'mcp' }) expect(screen.getAllByText('MCP')).toHaveLength(2) expect(screen.getByText('common.mcpPage.description')).toBeInTheDocument() expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( 'href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#mcp', ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) it('renders the custom tool header for the custom tool section', () => { renderIntegrationsPage({ section: 'custom-tool' }) expect(screen.getAllByText('common.settings.swaggerAPIAsTool')).toHaveLength(2) expect(screen.getByText('common.swaggerAPIAsToolPage.description')).toBeInTheDocument() expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( 'href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#swagger-api', ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) it.each([ [ 'data-source', 'common.settings.dataSource', 'common.dataSourcePage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/datasource-plugin#data-source-plugin-types', ], ] as const)('renders the %s header with a docs link', (section, title, description, href) => { renderIntegrationsPage({ section }) expect(screen.getAllByText(title)).toHaveLength(2) expect(screen.getByText(description)).toBeInTheDocument() expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( 'href', href, ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) it('renders the custom endpoint header with toolbar and docs link', () => { renderIntegrationsPage({ section: 'custom-endpoint' }) expect(screen.getAllByText('common.settings.customEndpoint')).toHaveLength(2) expect(screen.getByText('common.apiBasedExtensionPage.description')).toBeInTheDocument() expect(screen.getByTestId('api-extension-toolbar')).toBeInTheDocument() expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( 'href', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/endpoint', ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) it.each([ [ 'trigger', 'plugin.categorySingle.trigger', 'common.triggerPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/trigger-plugin', ], [ 'extension', 'plugin.categorySingle.extension', 'common.extensionPage.description', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension', ], [ 'agent-strategy', 'plugin.categorySingle.agent', 'common.agentStrategyPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin', ], ] as const)('renders the %s header with a docs link', (section, title, description, href) => { renderIntegrationsPage({ section }) expect(screen.getAllByText(title)).toHaveLength(2) expect(screen.getByText(description)).toBeInTheDocument() expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( 'href', href, ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) it('renders the workflow as tool header with a docs link', () => { renderIntegrationsPage({ section: 'workflow-tool' }) expect(screen.getAllByText('workflow.common.workflowAsTool')).toHaveLength(2) expect(screen.getByText('common.workflowAsToolPage.description')).toBeInTheDocument() expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute( 'href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow', ) expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument() }) it.each([ ['builtin', 'common.toolsPage.description'], ['mcp', 'common.mcpPage.description'], ['custom-tool', 'common.swaggerAPIAsToolPage.description'], ['workflow-tool', 'common.workflowAsToolPage.description'], ['custom-endpoint', 'common.apiBasedExtensionPage.description'], ['data-source', 'common.dataSourcePage.description'], ['trigger', 'common.triggerPage.description'], ['extension', 'common.extensionPage.description'], ['agent-strategy', 'common.agentStrategyPage.description'], ] as const)('renders an unbordered header for %s', (section, description) => { renderIntegrationsPage({ section }) expect( screen.getByText(description).parentElement?.parentElement?.parentElement, ).not.toHaveClass('border-b', 'border-divider-subtle') }) it.each(['builtin', 'trigger', 'extension', 'agent-strategy'] as const)( 'renders plugin update settings action in the category toolbar for %s', (section) => { renderIntegrationsPage({ section }) expect( screen.getByRole('button', { name: 'plugin.autoUpdate.autoUpdate' }), ).toBeInTheDocument() }, ) it('opens the integrations marketplace path from the install dropdown marketplace action', async () => { const user = userEvent.setup() renderIntegrationsPage({ section: 'builtin' }) await user.click(screen.getByRole('button', { name: 'plugin install' })) expect(mockWindowOpen).toHaveBeenCalledWith( expect.stringContaining('/plugins/tool?source='), '_blank', 'noopener,noreferrer', ) expect(mockRouterPush).not.toHaveBeenCalled() }) it('hides the install action and category installs when install permission is unavailable', () => { mockCanManagement.mockReturnValue(false) renderIntegrationsPage({ section: 'trigger' }) expect( screen.queryByLabelText('plugin.privilege.noInstallPermissionTooltip'), ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'plugin install' })).not.toBeInTheDocument() expect(screen.getByTestId('plugin-category-trigger')).toHaveAttribute( 'data-can-install', 'false', ) }) it('hides the debug action when debug permission is unavailable', () => { mockCanDebugger.mockReturnValue(false) renderIntegrationsPage({ section: 'trigger' }) expect( screen.queryByLabelText('plugin.privilege.noDebugPermissionTooltip'), ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'plugin debug' })).not.toBeInTheDocument() }) it('hides plugin update settings action when permission management is unavailable', () => { mockCanSetPermissions.mockReturnValue(false) renderIntegrationsPage({ section: 'trigger' }) expect( screen.queryByRole('button', { name: 'plugin.autoUpdate.autoUpdate' }), ).not.toBeInTheDocument() }) it('opens the sidebar plugin permissions quick settings and updates permissions', async () => { const user = userEvent.setup() renderIntegrationsPage({ section: 'provider' }) await user.click(screen.getByRole('button', { name: 'plugin.privilege.permissions' })) const dialog = screen.getByRole('dialog', { name: 'plugin.privilege.permissions' }) expect(within(dialog).getByText('plugin.privilege.quickWhoCanInstall')).toBeInTheDocument() expect(within(dialog).getByText('plugin.privilege.quickWhoCanDebug')).toBeInTheDocument() await user.click( within(dialog).getByRole('radio', { name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.noone', }), ) expect(mockSetReferenceSettings).toHaveBeenCalledWith({ install_permission: 'noone', debug_permission: 'admins', }) await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' })) expect( screen.queryByRole('dialog', { name: 'plugin.privilege.permissions' }), ).not.toBeInTheDocument() }) it('hides the sidebar plugin permissions quick settings when permission management is unavailable', () => { mockCanSetPermissions.mockReturnValue(false) renderIntegrationsPage({ section: 'provider' }) expect( screen.queryByRole('button', { name: 'plugin.privilege.permissions' }), ).not.toBeInTheDocument() expect(screen.queryByText('plugin.privilege.quickWhoCanInstall')).not.toBeInTheDocument() expect(screen.queryByText('plugin.privilege.quickWhoCanDebug')).not.toBeInTheDocument() }) it('uses the no-action sidebar spacing when install permission is unavailable', () => { mockCanManagement.mockReturnValue(false) renderIntegrationsPage({ section: 'provider' }) expect( screen.getByText('common.settings.integrations').parentElement?.parentElement, ).toHaveClass('mb-3', 'pt-1', 'pb-0.5') expect( screen.getByRole('link', { name: 'common.settings.provider' }).parentElement, ).toHaveClass('py-4') }) it('keeps the integrations sidebar expanded without a collapse control', () => { const { container } = renderIntegrationsPage({ section: 'provider' }) expect(container.firstElementChild).toHaveStyle({ '--model-provider-warning-left': 'calc(240px + 200px)', }) expect(screen.getByText('common.settings.integrations')).toBeInTheDocument() expect(screen.getByText('common.settings.integrations')).toHaveClass( 'title-2xl-semi-bold', 'text-text-primary', ) expect(screen.getByText('common.settings.integrations').parentElement).toHaveClass( 'h-6', 'items-center', ) expect(screen.getByRole('button', { name: 'plugin install' })).toHaveAttribute( 'data-show-trigger-arrow', 'false', ) expect(screen.getByRole('button', { name: 'plugin install' })).toHaveClass('justify-start') expect(screen.getByRole('button', { name: 'plugin tasks' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveTextContent( 'plugin.debugInfo.title', ) expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveClass( 'h-8', 'w-full', 'gap-2', 'rounded-lg', 'py-1', 'pr-1', 'pl-2', 'system-sm-medium', ) expect(screen.getByRole('button', { name: 'plugin debug' }).parentElement).toHaveClass('w-46') expect(screen.getByRole('button', { name: 'plugin.privilege.permissions' })).toHaveTextContent( 'plugin.privilege.permissions', ) expect(screen.getByRole('button', { name: 'plugin.privilege.permissions' })).toHaveClass( 'h-8', 'w-full', 'gap-2', 'rounded-lg', 'py-1', 'pr-1', 'pl-2', 'system-sm-medium', ) expect(screen.queryByText('common.settings.swaggerAPIAsTool')).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: 'MCP' })).not.toBeInTheDocument() expect(screen.getByRole('link', { name: 'common.settings.customEndpoint' })).toHaveAttribute( 'href', '/integrations/custom-endpoint', ) expect(screen.getByRole('link', { name: 'plugin.categorySingle.trigger' })).toHaveAttribute( 'href', '/integrations/trigger', ) expect( screen.queryByRole('button', { name: 'common.settings.collapse' }), ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'common.settings.expand' })).not.toBeInTheDocument() }) })