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

@ -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)
})
})
})