test(web): add coverage for workflow plugin install flows

This commit is contained in:
yyh
2026-03-12 16:07:50 +08:00
parent 64a66f2adc
commit e2f433bab9
4 changed files with 608 additions and 3 deletions

View File

@ -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> = {}): 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 => ({
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',
})
})
})

View File

@ -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
}) => (
<div
data-testid="header-modals"
data-target-version={targetVersion?.version ?? ''}
data-is-downgrade={String(isDowngrade)}
data-auto-upgrade={String(isAutoUpgradeEnabled)}
/>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/operation-dropdown', () => ({
default: ({ detailUrl, onInfo, onCheckVersion, onRemove }: {
detailUrl: string
onInfo: () => void
onCheckVersion: () => void
onRemove: () => void
}) => (
<div data-testid="operation-dropdown" data-detail-url={detailUrl}>
<button type="button" onClick={onInfo}>info</button>
<button type="button" onClick={onCheckVersion}>check version</button>
<button type="button" onClick={onRemove}>remove</button>
</div>
),
}))
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
}) => (
<div data-testid="plugin-version-picker" data-disabled={String(Boolean(disabled))}>
{trigger}
<button
type="button"
onClick={() => onSelect({ version: '2.0.0', unique_identifier: 'plugin@2.0.0', isDowngrade: true })}
>
select version
</button>
</div>
),
}))
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> = {}): 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(<ProviderCardActions detail={createDetail()} />)
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(<ProviderCardActions detail={createDetail()} />)
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(<ProviderCardActions detail={createDetail()} />)
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(<ProviderCardActions detail={createDetail()} />)
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(
<ProviderCardActions detail={createDetail({
source: PluginSource.github,
meta: {
repo: 'langgenius/provider-plugin',
version: '1.0.0',
package: 'provider-plugin.difypkg',
},
})}
/>,
)
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()
})
})

View File

@ -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<string, { checkValid: CheckValidFn, metaData: { isStart: boolean, isRequired: boolean } }> = {}
let mockModelProviders: Array<{ provider: string }> = []
let mockUsedVars: string[][] = []
const mockAvailableVarMap: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {}
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<string, { availableVars: never[] }> = {}
const map: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {}
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',
])
})
})
// ---------------------------------------------------------------------------

View File

@ -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> = {}): 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> = {}): 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)
})
})
})