diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.spec.tsx new file mode 100644 index 0000000000..3a2e28720c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.spec.tsx @@ -0,0 +1,127 @@ +import type { Credential, ModelProvider } from '../../declarations' +import { act, renderHook } from '@testing-library/react' +import Toast from '@/app/components/base/toast' +import { useActivateCredential } from './use-activate-credential' + +const mockMutate = vi.fn() +const mockUpdateModelProviders = vi.fn() +const mockUpdateModelList = vi.fn() +let mockIsPending = false + +vi.mock('@/service/use-models', () => ({ + useActiveProviderCredential: () => ({ + mutate: mockMutate, + isPending: mockIsPending, + }), +})) + +vi.mock('../../hooks', () => ({ + useUpdateModelProviders: () => mockUpdateModelProviders, + useUpdateModelList: () => mockUpdateModelList, +})) + +const createProvider = (overrides: Partial = {}): ModelProvider => ({ + provider: 'langgenius/openai/openai', + supported_model_types: ['llm', 'text-embedding'], + custom_configuration: { + current_credential_id: 'cred-1', + available_credentials: [ + { credential_id: 'cred-1', credential_name: 'Primary' }, + { credential_id: 'cred-2', credential_name: 'Backup' }, + ], + }, + ...overrides, +} as unknown as ModelProvider) + +const createCredential = (overrides: Partial = {}): Credential => ({ + credential_id: 'cred-2', + credential_name: 'Backup', + ...overrides, +} as Credential) + +describe('useActivateCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsPending = false + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + it('should expose the current credential id by default', () => { + const { result } = renderHook(() => useActivateCredential(createProvider())) + + expect(result.current.selectedCredentialId).toBe('cred-1') + expect(result.current.isActivating).toBe(false) + }) + + it('should expose the pending mutation state', () => { + mockIsPending = true + + const { result } = renderHook(() => useActivateCredential(createProvider())) + + expect(result.current.isActivating).toBe(true) + }) + + it('should skip mutation when the selected credential is already active', () => { + const { result } = renderHook(() => useActivateCredential(createProvider())) + + act(() => { + result.current.activate(createCredential({ credential_id: 'cred-1' })) + }) + + expect(mockMutate).not.toHaveBeenCalled() + expect(result.current.selectedCredentialId).toBe('cred-1') + }) + + it('should optimistically select the credential and refresh provider data on success', () => { + const { result } = renderHook(() => useActivateCredential(createProvider())) + + act(() => { + result.current.activate(createCredential()) + }) + + expect(result.current.selectedCredentialId).toBe('cred-2') + expect(mockMutate).toHaveBeenCalledWith( + { credential_id: 'cred-2' }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) + + const [, callbacks] = mockMutate.mock.calls[0] + + act(() => { + callbacks.onSuccess() + }) + + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.api.actionSuccess', + }) + expect(mockUpdateModelProviders).toHaveBeenCalledTimes(1) + expect(mockUpdateModelList).toHaveBeenNthCalledWith(1, 'llm') + expect(mockUpdateModelList).toHaveBeenNthCalledWith(2, 'text-embedding') + }) + + it('should reset the optimistic selection and show an error toast when activation fails', () => { + const { result } = renderHook(() => useActivateCredential(createProvider())) + + act(() => { + result.current.activate(createCredential()) + }) + + expect(result.current.selectedCredentialId).toBe('cred-2') + + const [, callbacks] = mockMutate.mock.calls[0] + + act(() => { + callbacks.onError() + }) + + expect(result.current.selectedCredentialId).toBe('cred-1') + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.actionMsg.modifiedUnsuccessfully', + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.spec.tsx new file mode 100644 index 0000000000..a4d2c9724f --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.spec.tsx @@ -0,0 +1,226 @@ +import type { ReactNode } from 'react' +import type { PluginDetail } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { PluginSource } from '@/app/components/plugins/types' +import ProviderCardActions from './provider-card-actions' + +const mockHandleUpdate = vi.fn() +const mockHandleUpdatedFromMarketplace = vi.fn() +const mockHandleDelete = vi.fn() +const mockGetMarketplaceUrl = vi.fn() +const mockShowPluginInfo = vi.fn() +const mockShowDeleteConfirm = vi.fn() +const mockSetTargetVersion = vi.fn() +const mockSetVersionPickerOpen = vi.fn() + +let mockHeaderState = { + modalStates: { + showPluginInfo: mockShowPluginInfo, + showDeleteConfirm: mockShowDeleteConfirm, + }, + versionPicker: { + isShow: false, + setIsShow: mockSetVersionPickerOpen, + setTargetVersion: mockSetTargetVersion, + targetVersion: undefined, + isDowngrade: false, + }, + hasNewVersion: true, + isAutoUpgradeEnabled: false, + isFromMarketplace: true, + isFromGitHub: false, +} + +vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/hooks', () => ({ + useDetailHeaderState: () => mockHeaderState, + usePluginOperations: () => ({ + handleUpdate: mockHandleUpdate, + handleUpdatedFromMarketplace: mockHandleUpdatedFromMarketplace, + handleDelete: mockHandleDelete, + }), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/components', () => ({ + HeaderModals: ({ targetVersion, isDowngrade, isAutoUpgradeEnabled }: { + targetVersion?: { version: string, unique_identifier: string } + isDowngrade: boolean + isAutoUpgradeEnabled: boolean + }) => ( +
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/operation-dropdown', () => ({ + default: ({ detailUrl, onInfo, onCheckVersion, onRemove }: { + detailUrl: string + onInfo: () => void + onCheckVersion: () => void + onRemove: () => void + }) => ( +
+ + + +
+ ), +})) + +vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({ + default: ({ trigger, onSelect, disabled }: { + trigger: ReactNode + onSelect: (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void + disabled?: boolean + }) => ( +
+ {trigger} + +
+ ), +})) + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: (...args: unknown[]) => mockGetMarketplaceUrl(...args), +})) + +const createDetail = (overrides: Partial = {}): PluginDetail => ({ + plugin_id: 'plugin-id', + plugin_unique_identifier: 'plugin-id@1.0.0', + name: 'provider-plugin', + source: PluginSource.marketplace, + version: '1.0.0', + latest_version: '2.0.0', + latest_unique_identifier: 'plugin-id@2.0.0', + declaration: { + author: 'langgenius', + name: 'provider-plugin', + }, + meta: undefined, + ...overrides, +} as PluginDetail) + +describe('ProviderCardActions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHeaderState = { + modalStates: { + showPluginInfo: mockShowPluginInfo, + showDeleteConfirm: mockShowDeleteConfirm, + }, + versionPicker: { + isShow: false, + setIsShow: mockSetVersionPickerOpen, + setTargetVersion: mockSetTargetVersion, + targetVersion: undefined, + isDowngrade: false, + }, + hasNewVersion: true, + isAutoUpgradeEnabled: false, + isFromMarketplace: true, + isFromGitHub: false, + } + mockGetMarketplaceUrl.mockReturnValue('https://marketplace.example.com/plugins/langgenius/provider-plugin') + }) + + it('should render version controls for marketplace plugins and handle manual version selection', () => { + render() + + expect(screen.getByText('1.0.0')).toBeInTheDocument() + expect(screen.getByTestId('plugin-version-picker')).toHaveAttribute('data-disabled', 'false') + + fireEvent.click(screen.getByRole('button', { name: 'select version' })) + + expect(mockSetTargetVersion).toHaveBeenCalledWith({ + version: '2.0.0', + unique_identifier: 'plugin@2.0.0', + isDowngrade: true, + }) + expect(mockHandleUpdate).toHaveBeenCalledWith(true) + }) + + it('should trigger the latest marketplace update when clicking the update button', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' })) + + expect(mockSetTargetVersion).toHaveBeenCalledWith({ + version: '2.0.0', + unique_identifier: 'plugin-id@2.0.0', + }) + expect(mockHandleUpdate).toHaveBeenCalledWith() + }) + + it('should pass the marketplace detail url to the operation dropdown', () => { + render() + + expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('/plugins/langgenius/provider-plugin', { + language: 'en-US', + theme: 'light', + }) + expect(screen.getByTestId('operation-dropdown')).toHaveAttribute( + 'data-detail-url', + 'https://marketplace.example.com/plugins/langgenius/provider-plugin', + ) + }) + + it('should relay operation dropdown actions', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'info' })) + fireEvent.click(screen.getByRole('button', { name: 'check version' })) + fireEvent.click(screen.getByRole('button', { name: 'remove' })) + + expect(mockShowPluginInfo).toHaveBeenCalledTimes(1) + expect(mockHandleUpdate).toHaveBeenCalledTimes(1) + expect(mockShowDeleteConfirm).toHaveBeenCalledTimes(1) + }) + + it('should use the GitHub repo url and skip marketplace version preselection for GitHub plugins', () => { + mockHeaderState = { + ...mockHeaderState, + hasNewVersion: false, + isFromMarketplace: false, + isFromGitHub: true, + } + + render( + , + ) + + expect(screen.getByTestId('plugin-version-picker')).toHaveAttribute('data-disabled', 'true') + expect(screen.getByTestId('operation-dropdown')).toHaveAttribute( + 'data-detail-url', + 'https://github.com/langgenius/provider-plugin', + ) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' })) + + expect(mockSetTargetVersion).not.toHaveBeenCalled() + expect(mockHandleUpdate).toHaveBeenCalledWith() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts index d42e2149f7..66dcf09b36 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -40,6 +40,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () type CheckValidFn = (data: CommonNodeType, t: unknown, extra?: unknown) => { errorMessage: string } const mockNodesMap: Record = {} +let mockModelProviders: Array<{ provider: string }> = [] +let mockUsedVars: string[][] = [] +const mockAvailableVarMap: Record }> }> = {} vi.mock('../use-nodes-meta-data', () => ({ useNodesMetaData: () => ({ @@ -50,10 +53,10 @@ vi.mock('../use-nodes-meta-data', () => ({ vi.mock('../use-nodes-available-var-list', () => ({ default: (nodes: Node[]) => { - const map: Record = {} + const map: Record }> }> = {} if (nodes) { for (const n of nodes) - map[n.id] = { availableVars: [] } + map[n.id] = mockAvailableVarMap[n.id] ?? { availableVars: [] } } return map }, @@ -61,7 +64,7 @@ vi.mock('../use-nodes-available-var-list', () => ({ })) vi.mock('../../nodes/_base/components/variable/utils', () => ({ - getNodeUsedVars: () => [], + getNodeUsedVars: () => mockUsedVars, isSpecialVar: () => false, })) @@ -91,6 +94,11 @@ vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en', })) +vi.mock('@/context/provider-context', () => ({ + useProviderContextSelector: (selector: (state: { modelProviders: Array<{ provider: string }> }) => unknown) => + selector({ modelProviders: mockModelProviders }), +})) + // useWorkflowNodes reads from WorkflowContext (real store via renderWorkflowHook) // --------------------------------------------------------------------------- @@ -125,6 +133,9 @@ beforeEach(() => { resetReactFlowMockState() resetFixtureCounters() Object.keys(mockNodesMap).forEach(k => delete mockNodesMap[k]) + Object.keys(mockAvailableVarMap).forEach(k => delete mockAvailableVarMap[k]) + mockModelProviders = [] + mockUsedVars = [] setupNodesMap() }) @@ -236,6 +247,9 @@ describe('useChecklist', () => { expect(warning).toBeDefined() expect(warning!.canNavigate).toBe(false) expect(warning!.disableGoTo).toBe(true) + expect(warning!.isPluginMissing).toBe(true) + expect(warning!.pluginUniqueIdentifier).toBe('plugin/tool@0.0.1') + expect(warning!.errorMessages).toContain('workflow.nodes.common.pluginNotInstalled') }) it('should report required node types that are missing', () => { @@ -282,6 +296,73 @@ describe('useChecklist', () => { const alienWarning = result.current.find((item: ChecklistItem) => item.id === 'alien') expect(alienWarning).toBeUndefined() }) + + it('should report configure model errors when an llm model provider plugin is missing', () => { + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const llmNode = createNode({ + id: 'llm', + data: { + type: BlockEnum.LLM, + title: 'LLM', + model: { + provider: 'langgenius/openai/openai', + }, + }, + }) + + const edges = [ + createEdge({ source: 'start', target: 'llm' }), + ] + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, llmNode], edges), + ) + + const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') + expect(warning).toBeDefined() + expect(warning!.errorMessages).toContain('workflow.errorMsg.configureModel') + expect(warning!.canNavigate).toBe(true) + }) + + it('should accumulate validation and invalid variable errors for the same node', () => { + mockNodesMap[BlockEnum.LLM] = { + checkValid: () => ({ errorMessage: 'Model not configured' }), + metaData: { isStart: false, isRequired: false }, + } + mockUsedVars = [['start', 'missingVar']] + mockAvailableVarMap.llm = { + availableVars: [ + { + nodeId: 'start', + vars: [{ variable: 'existingVar' }], + }, + ], + } + + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const llmNode = createNode({ + id: 'llm', + data: { + type: BlockEnum.LLM, + title: 'LLM', + }, + }) + + const edges = [ + createEdge({ source: 'start', target: 'llm' }), + ] + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, llmNode], edges), + ) + + const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') + expect(warning).toBeDefined() + expect(warning!.errorMessages).toEqual([ + 'Model not configured', + 'workflow.errorMsg.invalidVariable', + ]) + }) }) // --------------------------------------------------------------------------- diff --git a/web/app/components/workflow/utils/plugin-install-check.spec.ts b/web/app/components/workflow/utils/plugin-install-check.spec.ts new file mode 100644 index 0000000000..0a8e740825 --- /dev/null +++ b/web/app/components/workflow/utils/plugin-install-check.spec.ts @@ -0,0 +1,171 @@ +import type { TriggerWithProvider } from '../block-selector/types' +import type { CommonNodeType, ToolWithProvider } from '../types' +import { CollectionType } from '@/app/components/tools/types' +import { BlockEnum } from '../types' +import { + isNodePluginMissing, + isPluginDependentNode, + matchDataSource, + matchToolInCollection, + matchTriggerProvider, +} from './plugin-install-check' + +const createTool = (overrides: Partial = {}): ToolWithProvider => ({ + id: 'langgenius/search/search', + name: 'search', + plugin_id: 'plugin-search', + provider: 'search-provider', + plugin_unique_identifier: 'plugin-search@1.0.0', + ...overrides, +} as ToolWithProvider) + +const createTriggerProvider = (overrides: Partial = {}): TriggerWithProvider => ({ + id: 'trigger-provider-id', + name: 'trigger-provider', + plugin_id: 'trigger-plugin', + ...overrides, +} as TriggerWithProvider) + +describe('plugin install check', () => { + describe('isPluginDependentNode', () => { + it('should return true for plugin dependent node types', () => { + expect(isPluginDependentNode(BlockEnum.Tool)).toBe(true) + expect(isPluginDependentNode(BlockEnum.DataSource)).toBe(true) + expect(isPluginDependentNode(BlockEnum.TriggerPlugin)).toBe(true) + }) + + it('should return false for non-plugin node types', () => { + expect(isPluginDependentNode(BlockEnum.LLM)).toBe(false) + }) + }) + + describe('matchToolInCollection', () => { + const collection = [createTool()] + + it('should match a tool by plugin id', () => { + expect(matchToolInCollection(collection, { plugin_id: 'plugin-search' })).toEqual(collection[0]) + }) + + it('should match a tool by legacy provider id', () => { + expect(matchToolInCollection(collection, { provider_id: 'search' })).toEqual(collection[0]) + }) + + it('should match a tool by provider name', () => { + expect(matchToolInCollection(collection, { provider_name: 'search' })).toEqual(collection[0]) + }) + + it('should return undefined when no tool matches', () => { + expect(matchToolInCollection(collection, { plugin_id: 'missing-plugin' })).toBeUndefined() + }) + }) + + describe('matchTriggerProvider', () => { + const providers = [createTriggerProvider()] + + it('should match a trigger provider by name', () => { + expect(matchTriggerProvider(providers, { provider_name: 'trigger-provider' })).toEqual(providers[0]) + }) + + it('should match a trigger provider by id', () => { + expect(matchTriggerProvider(providers, { provider_id: 'trigger-provider-id' })).toEqual(providers[0]) + }) + + it('should match a trigger provider by plugin id', () => { + expect(matchTriggerProvider(providers, { plugin_id: 'trigger-plugin' })).toEqual(providers[0]) + }) + }) + + describe('matchDataSource', () => { + const dataSources = [createTool({ + provider: 'knowledge-provider', + plugin_id: 'knowledge-plugin', + plugin_unique_identifier: 'knowledge-plugin@1.0.0', + })] + + it('should match a data source by unique identifier', () => { + expect(matchDataSource(dataSources, { plugin_unique_identifier: 'knowledge-plugin@1.0.0' })).toEqual(dataSources[0]) + }) + + it('should match a data source by plugin id', () => { + expect(matchDataSource(dataSources, { plugin_id: 'knowledge-plugin' })).toEqual(dataSources[0]) + }) + + it('should match a data source by provider name', () => { + expect(matchDataSource(dataSources, { provider_name: 'knowledge-provider' })).toEqual(dataSources[0]) + }) + }) + + describe('isNodePluginMissing', () => { + it('should report missing tool plugins when the collection is loaded but unmatched', () => { + const node = { + type: BlockEnum.Tool, + title: 'Tool', + desc: '', + provider_type: CollectionType.builtIn, + provider_id: 'missing-provider', + plugin_unique_identifier: 'missing-plugin@1.0.0', + } as CommonNodeType + + expect(isNodePluginMissing(node, { builtInTools: [createTool()] })).toBe(true) + }) + + it('should keep tool nodes installable when the collection has not loaded yet', () => { + const node = { + type: BlockEnum.Tool, + title: 'Tool', + desc: '', + provider_type: CollectionType.builtIn, + provider_id: 'missing-provider', + plugin_unique_identifier: 'missing-plugin@1.0.0', + } as CommonNodeType + + expect(isNodePluginMissing(node, { builtInTools: undefined })).toBe(false) + }) + + it('should ignore unmatched tool nodes without plugin identifiers', () => { + const node = { + type: BlockEnum.Tool, + title: 'Tool', + desc: '', + provider_type: CollectionType.builtIn, + provider_id: 'missing-provider', + } as CommonNodeType + + expect(isNodePluginMissing(node, { builtInTools: [createTool()] })).toBe(false) + }) + + it('should report missing trigger plugins when no provider matches', () => { + const node = { + type: BlockEnum.TriggerPlugin, + title: 'Trigger', + desc: '', + provider_id: 'missing-trigger', + plugin_unique_identifier: 'trigger-plugin@1.0.0', + } as CommonNodeType + + expect(isNodePluginMissing(node, { triggerPlugins: [createTriggerProvider()] })).toBe(true) + }) + + it('should report missing data source plugins when the list is loaded but unmatched', () => { + const node = { + type: BlockEnum.DataSource, + title: 'Data Source', + desc: '', + provider_name: 'missing-provider', + plugin_unique_identifier: 'missing-data-source@1.0.0', + } as CommonNodeType + + expect(isNodePluginMissing(node, { dataSourceList: [createTool()] })).toBe(true) + }) + + it('should return false for unsupported node types', () => { + const node = { + type: BlockEnum.LLM, + title: 'LLM', + desc: '', + } as CommonNodeType + + expect(isNodePluginMissing(node, {})).toBe(false) + }) + }) +})