test: add coverage for model provider and workflow edge cases

This commit is contained in:
CodingOnStar
2026-03-16 16:15:45 +08:00
parent 4f6d880cf2
commit 8b62b99d9d
43 changed files with 3216 additions and 12 deletions

View File

@ -419,6 +419,21 @@ describe('ModelParameterTrigger', () => {
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.disabled')
})
it('should apply expanded and warning styles when the trigger is open for a non-active status', () => {
const { unmount } = renderComponent()
const triggerContent = capturedModalProps?.renderTrigger({
open: true,
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
})
unmount()
const { container } = render(<>{triggerContent}</>)
expect(container.firstChild).toHaveClass('bg-state-base-hover')
expect(container.firstChild).toHaveClass('!bg-[#FFFAEB]')
})
})
describe('edge cases', () => {

View File

@ -0,0 +1,75 @@
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { useLlmModelPluginInstalled } from './use-llm-model-plugin-installed'
let mockModelProviders: Array<{ provider: string }> = []
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: <T>(selector: (state: { modelProviders: Array<{ provider: string }> }) => T): T =>
selector({ modelProviders: mockModelProviders }),
}))
const createWorkflowNodesMap = (node: Record<string, unknown>): WorkflowNodesMap =>
({
target: {
title: 'Target',
type: BlockEnum.Start,
...node,
},
} as unknown as WorkflowNodesMap)
describe('useLlmModelPluginInstalled', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelProviders = []
})
it('should return true when the node is missing', () => {
const { result } = renderHook(() => useLlmModelPluginInstalled('target', undefined))
expect(result.current).toBe(true)
})
it('should return true when the node is not an LLM node', () => {
const workflowNodesMap = createWorkflowNodesMap({
id: 'target',
type: BlockEnum.Start,
})
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
expect(result.current).toBe(true)
})
it('should return true when the matching model plugin is installed', () => {
mockModelProviders = [
{ provider: 'langgenius/openai/openai' },
{ provider: 'langgenius/anthropic/claude' },
]
const workflowNodesMap = createWorkflowNodesMap({
id: 'target',
type: BlockEnum.LLM,
modelProvider: 'langgenius/openai/gpt-4.1',
})
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
expect(result.current).toBe(true)
})
it('should return false when the matching model plugin is not installed', () => {
mockModelProviders = [
{ provider: 'langgenius/anthropic/claude' },
]
const workflowNodesMap = createWorkflowNodesMap({
id: 'target',
type: BlockEnum.LLM,
modelProvider: 'langgenius/openai/gpt-4.1',
})
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
expect(result.current).toBe(false)
})
})

View File

@ -0,0 +1,57 @@
import type { ComponentType, InputHTMLAttributes } from 'react'
import { render, screen } from '@testing-library/react'
const mockNotify = vi.fn()
type AutosizeInputProps = InputHTMLAttributes<HTMLInputElement> & {
inputClassName?: string
}
const MockAutosizeInput: ComponentType<AutosizeInputProps> = ({ inputClassName, ...props }) => (
<input data-testid="autosize-input" className={inputClassName} {...props} />
)
describe('TagInput autosize interop', () => {
afterEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('should support a namespace-style default export from react-18-input-autosize', async () => {
vi.doMock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.doMock('react-18-input-autosize', () => ({
default: {
default: MockAutosizeInput,
},
}))
const { default: TagInput } = await import('../index')
render(<TagInput items={[]} onChange={vi.fn()} />)
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should support a direct default export from react-18-input-autosize', async () => {
vi.doMock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.doMock('react-18-input-autosize', () => ({
default: MockAutosizeInput,
}))
const { default: TagInput } = await import('../index')
render(<TagInput items={[]} onChange={vi.fn()} />)
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,93 @@
import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import type { UsagePlanInfo } from '../../type'
import { render } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../../type'
import Pricing from '../index'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
vi.mock('../header', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<button data-testid="pricing-header-close" onClick={onClose}>close</button>
),
}))
vi.mock('../plan-switcher', () => ({
default: () => <div>plan-switcher</div>,
}))
vi.mock('../plans', () => ({
default: () => <div>plans</div>,
}))
vi.mock('../footer', () => ({
default: () => <div>footer</div>,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useGetPricingPageLanguage: vi.fn(),
}))
const buildUsage = (): UsagePlanInfo => ({
buildApps: 0,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
})
describe('Pricing dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
;(useProviderContext as Mock).mockReturnValue({
plan: {
type: Plan.sandbox,
usage: buildUsage(),
total: buildUsage(),
},
})
;(useGetPricingPageLanguage as Mock).mockReturnValue('en')
})
it('should only call onCancel when the dialog requests closing', () => {
const onCancel = vi.fn()
render(<Pricing onCancel={onCancel} />)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,57 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { useAppContext } from '@/context/app-context'
import EditWorkspaceModal from './index'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogCloseButton: ({ ...props }: Record<string, unknown>) => <button {...props} />,
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
describe('EditWorkspaceModal dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' },
isCurrentWorkspaceOwner: true,
} as never)
})
it('should only call onCancel when the dialog requests closing', () => {
const onCancel = vi.fn()
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
<EditWorkspaceModal onCancel={onCancel} />
</ToastContext.Provider>,
)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})

View File

@ -120,6 +120,21 @@ describe('EditWorkspaceModal', () => {
expect(screen.getByTestId('edit-workspace-error')).toBeInTheDocument()
})
it('should not submit when the form is submitted while save is disabled', async () => {
renderModal()
const saveButton = screen.getByTestId('edit-workspace-save')
const form = saveButton.closest('form')
expect(saveButton).toBeDisabled()
expect(form).not.toBeNull()
fireEvent.submit(form!)
expect(updateWorkspaceInfo).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
it('should disable confirm button for non-owners', async () => {
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,

View File

@ -0,0 +1,42 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import MenuDialog from './menu-dialog'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
describe('MenuDialog dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should only call onClose when the dialog requests closing', () => {
const onClose = vi.fn()
render(
<MenuDialog show={true} onClose={onClose}>
<div>Content</div>
</MenuDialog>,
)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -100,6 +100,42 @@ describe('deriveModelStatus', () => {
).toBe('api-key-unavailable')
})
it('should return credits-exhausted when model status is quota exceeded', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem({ status: ModelStatusEnum.quotaExceeded }),
createCredentialState({ priority: 'apiKey' }),
),
).toBe('credits-exhausted')
})
it('should return api-key-unavailable when model status is credential removed', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem({ status: ModelStatusEnum.credentialRemoved }),
createCredentialState({ priority: 'apiKey' }),
),
).toBe('api-key-unavailable')
})
it('should return incompatible when model status is no-permission', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem({ status: ModelStatusEnum.noPermission }),
createCredentialState({ priority: 'apiKey' }),
),
).toBe('incompatible')
})
it('should return active when model and credential state are available', () => {
expect(
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem(), createCredentialState()),

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
QuotaUnitEnum,
} from './declarations'
import ModelProviderPage from './index'
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
quota_unit: QuotaUnitEnum.times,
quota_limit: 100,
quota_used: 1,
last_used: 0,
is_valid: true,
}
vi.mock('@/config', () => ({
IS_CLOUD_EDITION: false,
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
enable_marketplace: false,
},
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [{
provider: 'openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}],
}),
}))
vi.mock('./hooks', () => ({
useDefaultModel: () => ({ data: null, isLoading: false }),
}))
vi.mock('./provider-added-card', () => ({
default: () => <div data-testid="provider-card" />,
}))
vi.mock('./provider-added-card/quota-panel', () => ({
default: () => <div data-testid="quota-panel" />,
}))
vi.mock('./system-model-selector', () => ({
default: () => <div data-testid="system-model-selector" />,
}))
vi.mock('./install-from-marketplace', () => ({
default: () => <div data-testid="install-from-marketplace" />,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({ data: undefined }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
plugins: {
checkInstalled: { queryOptions: () => ({}) },
latestVersions: { queryOptions: () => ({}) },
},
},
}))
describe('ModelProviderPage non-cloud branch', () => {
it('should skip the quota panel when cloud edition is disabled', () => {
render(<ModelProviderPage searchText="" />)
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
expect(screen.queryByTestId('quota-panel')).not.toBeInTheDocument()
})
})

View File

@ -61,8 +61,15 @@ describe('InstallFromMarketplace', () => {
it('should collapse when clicked', () => {
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
fireEvent.click(screen.getByText('common.modelProvider.installProvider'))
const toggle = screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ })
fireEvent.click(toggle)
expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument()
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByTestId('plugin-list')).toBeInTheDocument()
})
it('should show loading state', () => {

View File

@ -0,0 +1,271 @@
import type { ReactNode } from 'react'
import type { Credential, ModelProvider } from '../declarations'
import { act, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelModalModeEnum } from '../declarations'
import ModelModal from './index'
type DialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
type AlertDialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let mockLanguage = 'en_US'
let latestDialogOnOpenChange: DialogProps['onOpenChange']
let latestAlertDialogOnOpenChange: AlertDialogProps['onOpenChange']
let mockAvailableCredentials: Credential[] | undefined = []
let mockDeleteCredentialId: string | null = null
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
default: () => <div data-testid="auth-form" />,
}))
vi.mock('../model-auth', () => ({
CredentialSelector: ({ credentials }: { credentials: Credential[] }) => <div>{`credentials:${credentials.length}`}</div>,
}))
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestDialogOnOpenChange = onOpenChange
return <div>{children}</div>
},
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogCloseButton: () => <button type="button">close</button>,
}))
vi.mock('@/app/components/base/ui/alert-dialog', () => ({
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
latestAlertDialogOnOpenChange = onOpenChange
return <div>{children}</div>
},
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: false,
credentialData: {
credentials: {},
available_credentials: mockAvailableCredentials,
},
}),
useAuth: () => ({
handleSaveCredential: vi.fn(),
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: mockDeleteCredentialId,
closeConfirmDelete: mockCloseConfirmDelete,
openConfirmDelete: vi.fn(),
doingAction: false,
handleActiveCredential: vi.fn(),
}),
useModelFormSchemas: () => ({
formSchemas: [],
formValues: {},
modelNameAndTypeFormSchemas: [],
modelNameAndTypeFormValues: {},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
}),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: Record<string, string>) => value[mockLanguage] || value.en_US,
}))
vi.mock('../hooks', () => ({
useLanguage: () => mockLanguage,
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: '帮助' },
url: { en_US: 'https://example.com', zh_Hans: 'https://example.cn' },
},
icon_small: { en_US: '', zh_Hans: '' },
supported_model_types: [],
configurate_methods: [],
provider_credential_schema: { credential_form_schemas: [] },
model_credential_schema: {
model: { label: { en_US: 'Model', zh_Hans: '模型' }, placeholder: { en_US: 'Select', zh_Hans: '选择' } },
credential_form_schemas: [],
},
custom_configuration: {
status: 'active',
available_credentials: [],
custom_models: [],
can_added_models: [],
},
system_configuration: {
enabled: true,
current_quota_type: 'trial',
quota_configurations: [],
},
allow_custom_token: true,
...overrides,
} as unknown as ModelProvider)
describe('ModelModal dialog branches', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en_US'
latestDialogOnOpenChange = undefined
latestAlertDialogOnOpenChange = undefined
mockAvailableCredentials = []
mockDeleteCredentialId = null
})
it('should only cancel when the dialog reports it has closed', () => {
const onCancel = vi.fn()
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={onCancel}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
act(() => {
latestDialogOnOpenChange?.(true)
latestDialogOnOpenChange?.(false)
})
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should only close the confirm dialog when the alert dialog closes', () => {
mockDeleteCredentialId = 'cred-1'
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
act(() => {
latestAlertDialogOnOpenChange?.(true)
latestAlertDialogOnOpenChange?.(false)
})
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should pass an empty credential list to the selector when no credentials are available', () => {
mockAvailableCredentials = undefined
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
mode={ModelModalModeEnum.addCustomModelToModelList}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getByText('credentials:0')).toBeInTheDocument()
})
it('should hide the help link when provider help is missing', () => {
render(
<ModelModal
provider={createProvider({ help: undefined })}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.queryByRole('link', { name: 'Help' })).not.toBeInTheDocument()
})
it('should prevent navigation when help text exists without a help url', () => {
mockLanguage = 'zh_Hans'
render(
<ModelModal
provider={createProvider({
help: {
title: { en_US: 'English Help' },
url: '' as unknown as ModelProvider['help']['url'],
} as ModelProvider['help'],
})}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
const link = screen.getByText('English Help').closest('a')
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
expect(link).not.toBeNull()
link!.dispatchEvent(clickEvent)
expect(clickEvent.defaultPrevented).toBe(true)
})
it('should fall back to localized and english help urls when titles are missing', () => {
mockLanguage = 'zh_Hans'
const { rerender } = render(
<ModelModal
provider={createProvider({
help: {
url: { zh_Hans: 'https://example.cn', en_US: 'https://example.com' },
} as ModelProvider['help'],
})}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getByRole('link', { name: 'https://example.cn' })).toHaveAttribute('href', 'https://example.cn')
rerender(
<ModelModal
provider={createProvider({
help: {
url: { en_US: 'https://example.com' },
} as ModelProvider['help'],
})}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
const link = screen.getByRole('link', { name: 'https://example.com' })
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
link.dispatchEvent(clickEvent)
expect(link).toHaveAttribute('href', 'https://example.com')
expect(clickEvent.defaultPrevented).toBe(false)
})
})

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ModelParameterModal from './index'
let isAPIKeySet = true
@ -77,9 +77,10 @@ vi.mock('./parameter-item', () => ({
}))
vi.mock('../model-selector', () => ({
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (value: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector">
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
<button onClick={onHide}>hide</button>
</div>
),
}))
@ -231,4 +232,67 @@ describe('ModelParameterModal', () => {
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should support custom triggers, workflow mode, and missing default model values', async () => {
render(
<ModelParameterModal
{...defaultProps}
provider=""
modelId=""
isInWorkflow
renderTrigger={({ open }) => <span>{open ? 'Custom Open' : 'Custom Closed'}</span>}
/>,
)
fireEvent.click(screen.getByText('Custom Closed'))
expect(screen.getByText('Custom Open')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
fireEvent.click(screen.getByText('hide'))
await waitFor(() => {
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
})
it('should append the stop parameter in advanced mode and show the single-model debug label', () => {
render(
<ModelParameterModal
{...defaultProps}
isAdvancedMode
debugWithMultipleModel
/>,
)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
})
it('should render the empty loading fallback when rules resolve to an empty list', () => {
parameterRules = []
isRulesLoading = true
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
})
it('should support custom trigger placement outside workflow mode', () => {
render(
<ModelParameterModal
{...defaultProps}
renderTrigger={({ open }) => <span>{open ? 'Popup Open' : 'Popup Closed'}</span>}
/>,
)
fireEvent.click(screen.getByText('Popup Closed'))
expect(screen.getByText('Popup Open')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import ParameterItem from './parameter-item'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => (
<div>
<button type="button" onClick={() => onValueChange('updated')}>select-updated</button>
<button type="button" onClick={() => onValueChange(undefined)}>select-empty</button>
{children}
</div>
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <div>SelectValue</div>,
}))
describe('ParameterItem select mode', () => {
it('should propagate both explicit and empty select values', () => {
const onChange = vi.fn()
render(
<ParameterItem
parameterRule={{
name: 'format',
label: { en_US: 'Format', zh_Hans: 'Format' },
type: 'string',
options: ['json', 'text'],
required: false,
help: { en_US: 'Help', zh_Hans: 'Help' },
}}
value="json"
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'select-updated' }))
fireEvent.click(screen.getByRole('button', { name: 'select-empty' }))
expect(onChange).toHaveBeenNthCalledWith(1, 'updated')
expect(onChange).toHaveBeenNthCalledWith(2, undefined)
})
})

View File

@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import ModelSelector from './index'
type PopoverProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: PopoverProps['onOpenChange']
vi.mock('../hooks', () => ({
useCurrentProviderAndModel: () => ({
currentProvider: undefined,
currentModel: undefined,
}),
}))
vi.mock('@/app/components/base/ui/popover', () => ({
Popover: ({ children, onOpenChange }: PopoverProps) => {
latestOnOpenChange = onOpenChange
return <div>{children}</div>
},
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('./model-selector-trigger', () => ({
default: ({ open, readonly }: { open: boolean, readonly?: boolean }) => (
<span>
{open ? 'open' : 'closed'}
-
{readonly ? 'readonly' : 'editable'}
</span>
),
}))
vi.mock('./popup', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="popup">
<button type="button" onClick={onHide}>hide-popup</button>
</div>
),
}))
describe('ModelSelector popover branches', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should open and close through popover callbacks when editable', () => {
const onHide = vi.fn()
render(<ModelSelector modelList={[]} onHide={onHide} />)
act(() => {
latestOnOpenChange?.(true)
})
expect(screen.getByText('open-editable')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'hide-popup' }))
expect(screen.getByText('closed-editable')).toBeInTheDocument()
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should ignore popover open changes when readonly', () => {
render(<ModelSelector modelList={[]} readonly />)
act(() => {
latestOnOpenChange?.(true)
})
expect(screen.getByText('closed-readonly')).toBeInTheDocument()
})
})

View File

@ -12,12 +12,13 @@ import PopupItem from './popup-item'
const mockUpdateModelList = vi.hoisted(() => vi.fn())
const mockUpdateModelProviders = vi.hoisted(() => vi.fn())
const mockUseLanguage = vi.hoisted(() => vi.fn(() => 'en_US'))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useLanguage: () => 'en_US',
useLanguage: mockUseLanguage,
useUpdateModelList: () => mockUpdateModelList,
useUpdateModelProviders: () => mockUpdateModelProviders,
}
@ -43,6 +44,12 @@ vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/base/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockCredentialPanelState = vi.hoisted(() => vi.fn())
vi.mock('../provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: mockCredentialPanelState,
@ -56,7 +63,7 @@ vi.mock('../provider-added-card/use-change-provider-priority', () => ({
}))
vi.mock('../provider-added-card/model-auth-dropdown/dropdown-content', () => ({
default: () => null,
default: ({ onClose }: { onClose: () => void }) => <button type="button" onClick={onClose}>close dropdown</button>,
}))
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
@ -110,6 +117,7 @@ const makeProvider = (overrides: Record<string, unknown> = {}) => ({
describe('PopupItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseLanguage.mockReturnValue('en_US')
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider()],
})
@ -215,6 +223,24 @@ describe('PopupItem', () => {
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should fall back to english labels when the current language is unavailable', () => {
mockUseLanguage.mockReturnValue('zh_Hans')
render(
<PopupItem
model={makeModel({
label: { en_US: 'OpenAI only' } as Model['label'],
models: [makeModelItem({ label: { en_US: 'GPT-4 only' } as ModelItem['label'] })],
})}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByText('OpenAI only')).toBeInTheDocument()
expect(screen.getByText('GPT-4 only')).toBeInTheDocument()
})
it('should toggle collapsed state when clicking provider header', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
@ -235,6 +261,24 @@ describe('PopupItem', () => {
expect(screen.getByText('my-api-key')).toBeInTheDocument()
})
it('should render the inactive credential badge when the api key is not active', () => {
mockCredentialPanelState.mockReturnValue({
variant: 'api-inactive',
priority: 'apiKey',
supportsCredits: false,
showPrioritySwitcher: false,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'stale-key',
credits: 200,
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText('stale-key')).toBeInTheDocument()
expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull()
})
it('should show configure required when no credential name', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
@ -306,4 +350,14 @@ describe('PopupItem', () => {
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument()
})
it('should close the dropdown through dropdown content callbacks', () => {
const onHide = vi.fn()
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={onHide} />)
fireEvent.click(screen.getByRole('button', { name: 'close dropdown' }))
expect(onHide).toHaveBeenCalled()
})
})

View File

@ -553,4 +553,60 @@ describe('Popup', () => {
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should skip install requests when marketplace plugins are still loading', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockMarketplacePlugins.isLoading = true
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
await waitFor(() => {
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
})
})
it('should skip install requests when the marketplace plugin cannot be found', async () => {
mockMarketplacePlugins.current = []
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
await waitFor(() => {
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
})
})
it('should sort the selected provider to the top when a default model is provided', () => {
render(
<Popup
defaultModel={{ provider: 'anthropic', model: 'claude-3' }}
modelList={[
makeModel({ provider: 'openai', label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' } }),
makeModel({ provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' } }),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const providerLabels = screen.getAllByText(/openai|anthropic/)
expect(providerLabels[0]).toHaveTextContent('anthropic')
})
})

View File

@ -1,16 +1,41 @@
import { render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const mockTrialCredits = { credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false, nextCreditResetDate: undefined }
const mockSetShowPricingModal = vi.fn()
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
Trans: ({
i18nKey,
components,
}: {
i18nKey?: string
components: { upgradeLink: ReactNode }
}) => (
<>
{i18nKey}
{components.upgradeLink}
</>
),
}
})
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: () => mockSetShowPricingModal,
}))
describe('CreditsExhaustedAlert', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 0 })
Object.assign(mockTrialCredits, { credits: 0, totalCredits: 10_000 })
})
// Without API key fallback
@ -59,5 +84,21 @@ describe('CreditsExhaustedAlert', () => {
expect(screen.getByText(/9,800/)).toBeInTheDocument()
expect(screen.getByText(/10,000/)).toBeInTheDocument()
})
it('should cap progress at 100 percent when total credits are zero', () => {
Object.assign(mockTrialCredits, { credits: 0, totalCredits: 0 })
const { container } = render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(container.querySelector('.bg-components-progress-error-progress')).toHaveStyle({ width: '100%' })
})
it('should open the pricing modal when the upgrade link is clicked', () => {
const { container } = render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
fireEvent.click(container.querySelector('button') as HTMLButtonElement)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,152 @@
import type { ReactNode } from 'react'
import type { ModelProvider } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { act, fireEvent, render, screen } from '@testing-library/react'
import DropdownContent from './dropdown-content'
type AlertDialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: AlertDialogProps['onOpenChange']
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
const mockHandleOpenModal = vi.fn()
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete,
closeConfirmDelete: mockCloseConfirmDelete,
doingAction: false,
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: 'cred-1',
handleOpenModal: mockHandleOpenModal,
}),
}))
vi.mock('./use-activate-credential', () => ({
useActivateCredential: () => ({
selectedCredentialId: 'cred-1',
isActivating: false,
activate: vi.fn(),
}),
}))
vi.mock('@/app/components/base/ui/alert-dialog', () => ({
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
latestOnOpenChange = onOpenChange
return <div>{children}</div>
},
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogDescription: () => <div />,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('./api-key-section', () => ({
default: ({ credentials, onDelete }: { credentials: unknown[], onDelete: (credential?: unknown) => void }) => (
<div>
<span>{`credentials:${credentials.length}`}</span>
<button type="button" onClick={() => onDelete(undefined)}>delete-undefined</button>
</div>
),
}))
vi.mock('./credits-exhausted-alert', () => ({
default: () => <div>credits alert</div>,
}))
vi.mock('./credits-fallback-alert', () => ({
default: () => <div>fallback alert</div>,
}))
vi.mock('./usage-priority-section', () => ({
default: () => <div>priority section</div>,
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test',
custom_configuration: {
available_credentials: undefined,
},
system_configuration: {
enabled: true,
quota_configurations: [],
current_quota_type: 'trial',
},
configurate_methods: [],
supported_model_types: [],
...overrides,
} as unknown as ModelProvider)
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: false,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 0,
...overrides,
})
describe('DropdownContent dialog branches', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should fall back to an empty credential list when the provider has no credentials', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('credentials:0')).toBeInTheDocument()
})
it('should ignore delete requests without a credential payload', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'delete-undefined' }))
expect(mockOpenConfirmDelete).not.toHaveBeenCalled()
})
it('should only close the confirm dialog when the alert dialog reports closed', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
act(() => {
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
})
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
})
})

View File

@ -223,4 +223,42 @@ describe('ProviderCardActions', () => {
expect(mockSetTargetVersion).not.toHaveBeenCalled()
expect(mockHandleUpdate).toHaveBeenCalledWith()
})
it('should fall back to the detail name when declaration metadata is missing', () => {
render(
<ProviderCardActions
detail={createDetail({
declaration: undefined,
})}
/>,
)
expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('/plugins//provider-plugin', {
language: 'en-US',
theme: 'light',
})
})
it('should leave the detail url empty when a GitHub plugin has no repo or the source is unsupported', () => {
const { rerender } = render(
<ProviderCardActions
detail={createDetail({
source: PluginSource.github,
meta: undefined,
})}
/>,
)
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '')
rerender(
<ProviderCardActions
detail={createDetail({
source: PluginSource.local,
})}
/>,
)
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '')
})
})

View File

@ -12,7 +12,7 @@ let mockWorkspaceData: {
next_credit_reset_date: '2024-12-31',
}
let mockWorkspaceIsPending = false
let mockTrialModels: string[] = ['langgenius/openai/openai']
let mockTrialModels: string[] | undefined = ['langgenius/openai/openai']
let mockPlugins = [{
plugin_id: 'langgenius/openai',
latest_package_identifier: 'openai@1.0.0',
@ -39,9 +39,7 @@ vi.mock('@/service/use-common', () => ({
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
trial_models: mockTrialModels,
},
data: mockTrialModels ? { trial_models: mockTrialModels } : undefined,
}),
}))
@ -149,4 +147,37 @@ describe('QuotaPanel', () => {
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
})
})
it('should tolerate missing trial model configuration', () => {
mockTrialModels = undefined
render(<QuotaPanel providers={mockProviders} />)
expect(screen.queryByText('openai')).not.toBeInTheDocument()
})
it('should render installed custom providers without opening the install modal', () => {
render(<QuotaPanel providers={mockProviders} />)
expect(screen.getByLabelText(/modelAPI/)).toBeInTheDocument()
fireEvent.click(screen.getByText('openai'))
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
})
it('should show the supported-model tooltip for installed non-custom providers', () => {
render(
<QuotaPanel providers={[
{
provider: 'langgenius/openai/openai',
preferred_provider_type: 'system',
custom_configuration: { available_credentials: [] },
},
] as unknown as ModelProvider[]}
/>,
)
expect(screen.getByLabelText(/modelSupported/)).toBeInTheDocument()
})
})

View File

@ -172,6 +172,24 @@ describe('useCredentialPanelState', () => {
expect(result.current.variant).toBe('api-unavailable')
})
it('should return api-required-configure when credentials exist but the current credential is incomplete', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-required-configure')
})
})
// apiKeyOnly priority

View File

@ -74,6 +74,26 @@ describe('ProviderIcon', () => {
expect(screen.getByTestId('openai-icon')).toBeInTheDocument()
})
it('should apply custom className to special provider wrappers', () => {
const { rerender, container } = render(
<ProviderIcon
provider={createProvider({ provider: 'langgenius/anthropic/anthropic' })}
className="custom-wrapper"
/>,
)
expect(container.firstChild).toHaveClass('custom-wrapper')
rerender(
<ProviderIcon
provider={createProvider({ provider: 'langgenius/openai/openai' })}
className="custom-wrapper"
/>,
)
expect(container.firstChild).toHaveClass('custom-wrapper')
})
it('should render generic provider with image and label', () => {
const provider = createProvider({ label: { en_US: 'Custom', zh_Hans: '自定义' } })
render(<ProviderIcon provider={provider} />)
@ -94,4 +114,19 @@ describe('ProviderIcon', () => {
const img = screen.getByAltText('provider-icon') as HTMLImageElement
expect(img.src).toBe('https://example.com/dark.png')
})
it('should fall back to localized labels when available', () => {
const mockLang = vi.mocked(useLanguage)
mockLang.mockReturnValue('zh_Hans')
render(
<ProviderIcon
provider={createProvider({
label: { en_US: 'Custom', zh_Hans: '自定义' },
})}
/>,
)
expect(screen.getByText('自定义')).toBeInTheDocument()
})
})

View File

@ -115,6 +115,12 @@ describe('SystemModel', () => {
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
})
it('should render the primary button variant when configuration is required', () => {
render(<SystemModel {...defaultProps} notConfigured />)
expect(screen.getByRole('button', { name: /system model settings/i })).toHaveClass('btn-primary')
})
it('should close dialog when cancel is clicked', async () => {
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
@ -151,6 +157,27 @@ describe('SystemModel', () => {
})
})
it('should keep the dialog open when saving does not succeed', async () => {
mockUpdateDefaultModel.mockResolvedValueOnce({ result: 'failed' })
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
})
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
expect(mockNotify).not.toHaveBeenCalled()
expect(mockInvalidateDefaultModel).not.toHaveBeenCalled()
expect(mockUpdateModelList).not.toHaveBeenCalled()
})
it('should disable save when user is not workspace manager', async () => {
mockIsCurrentWorkspaceManager = false
render(<SystemModel {...defaultProps} />)

View File

@ -1,7 +1,17 @@
import type { TagKey } from '../constants'
import type { Plugin } from '../types'
import { describe, expect, it } from 'vitest'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import { PluginCategoryEnum } from '../types'
import { getValidCategoryKeys, getValidTagKeys } from '../utils'
import { getPluginCardIconUrl, getValidCategoryKeys, getValidTagKeys } from '../utils'
const createPlugin = (overrides: Partial<Pick<Plugin, 'from' | 'name' | 'org' | 'type'>> = {}): Pick<Plugin, 'from' | 'name' | 'org' | 'type'> => ({
from: 'github',
name: 'demo-plugin',
org: 'langgenius',
type: 'plugin',
...overrides,
})
describe('plugins/utils', () => {
describe('getValidTagKeys', () => {
@ -47,4 +57,31 @@ describe('plugins/utils', () => {
expect(getValidCategoryKeys('')).toBeUndefined()
})
})
describe('getPluginCardIconUrl', () => {
it('returns an empty string when icon is missing', () => {
expect(getPluginCardIconUrl(createPlugin(), undefined, 'tenant-1')).toBe('')
})
it('returns absolute urls and root-relative urls as-is', () => {
expect(getPluginCardIconUrl(createPlugin(), 'https://example.com/icon.png', 'tenant-1')).toBe('https://example.com/icon.png')
expect(getPluginCardIconUrl(createPlugin(), '/icons/demo.png', 'tenant-1')).toBe('/icons/demo.png')
})
it('builds the marketplace icon url for plugins and bundles', () => {
expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace' }), 'icon.png', 'tenant-1'))
.toBe(`${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`)
expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace', type: 'bundle' }), 'icon.png', 'tenant-1'))
.toBe(`${MARKETPLACE_API_PREFIX}/bundles/langgenius/demo-plugin/icon`)
})
it('falls back to the raw icon when tenant id is missing for non-marketplace plugins', () => {
expect(getPluginCardIconUrl(createPlugin(), 'icon.png', '')).toBe('icon.png')
})
it('builds the workspace icon url for tenant-scoped plugins', () => {
expect(getPluginCardIconUrl(createPlugin(), 'icon.png', 'tenant-1'))
.toBe(`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=tenant-1&filename=icon.png`)
})
})
})

View File

@ -0,0 +1,128 @@
import type { PluginDetail } from './types'
import { useQuery } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { consoleQuery } from '@/service/client'
import { usePluginsWithLatestVersion } from './hooks'
import { PluginSource } from './types'
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
plugins: {
latestVersions: {
queryOptions: vi.fn((options: unknown) => options),
},
},
},
}))
const createPlugin = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'plugin-1',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
name: 'demo-plugin',
plugin_id: 'plugin-1',
plugin_unique_identifier: 'plugin-1@1.0.0',
declaration: {} as PluginDetail['declaration'],
installation_id: 'installation-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'plugin-1@1.0.0',
source: PluginSource.marketplace,
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
describe('usePluginsWithLatestVersion', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useQuery).mockReturnValue({ data: undefined } as never)
})
it('should disable latest-version querying when there are no marketplace plugins', () => {
const plugins = [
createPlugin({ plugin_id: 'github-plugin', source: PluginSource.github }),
]
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
input: { body: { plugin_ids: [] } },
enabled: false,
})
expect(result.current).toEqual(plugins)
})
it('should return the original plugins when version data is unavailable', () => {
const plugins = [createPlugin()]
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(result.current).toEqual(plugins)
})
it('should keep plugins unchanged when a plugin has no matching latest version', () => {
const plugins = [createPlugin()]
vi.mocked(useQuery).mockReturnValue({
data: { versions: {} },
} as never)
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(result.current).toEqual(plugins)
})
it('should merge latest version fields for marketplace plugins with version data', () => {
const plugins = [
createPlugin(),
createPlugin({
id: 'plugin-2',
plugin_id: 'plugin-2',
plugin_unique_identifier: 'plugin-2@1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'plugin-2@1.0.0',
source: PluginSource.github,
}),
]
vi.mocked(useQuery).mockReturnValue({
data: {
versions: {
'plugin-1': {
version: '1.1.0',
unique_identifier: 'plugin-1@1.1.0',
status: 'deleted',
deprecated_reason: 'replaced',
alternative_plugin_id: 'plugin-3',
},
},
},
} as never)
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
input: { body: { plugin_ids: ['plugin-1'] } },
enabled: true,
})
expect(result.current).toEqual([
expect.objectContaining({
plugin_id: 'plugin-1',
latest_version: '1.1.0',
latest_unique_identifier: 'plugin-1@1.1.0',
status: 'deleted',
deprecated_reason: 'replaced',
alternative_plugin_id: 'plugin-3',
}),
plugins[1],
])
})
})

View File

@ -267,6 +267,34 @@ describe('useInstallMultiState', () => {
})
})
it('should fall back to latest_version when marketplace plugin version is missing', async () => {
mockMarketplaceData = {
data: {
list: [{
plugin: {
plugin_id: 'test-org/plugin-0',
org: 'test-org',
name: 'Test Plugin 0',
version: '',
latest_version: '2.0.0',
},
version: {
unique_identifier: 'plugin-0-uid',
},
}],
},
}
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]?.version).toBe('2.0.0')
})
})
it('should resolve marketplace dependency from organization and plugin fields', async () => {
mockMarketplaceData = createMarketplaceApiData([0])
@ -293,6 +321,44 @@ describe('useInstallMultiState', () => {
// ==================== Error Handling ====================
describe('Error Handling', () => {
it('should mark marketplace index as error when identifier misses plugin and version parts', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'invalid-identifier',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark marketplace index as error when identifier has an empty plugin segment', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'test-org/:1.0.0',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark marketplace index as error when identifier is missing', async () => {
const params = createDefaultParams({
allPlugins: [
@ -344,6 +410,19 @@ describe('useInstallMultiState', () => {
expect(result.current.errorIndexes).not.toContain(0)
})
})
it('should ignore marketplace requests whose dsl index cannot be mapped', () => {
const duplicatedMarketplaceDependency = createMarketplaceDependency(0)
const allPlugins = [duplicatedMarketplaceDependency] as Dependency[]
allPlugins.filter = vi.fn(() => [duplicatedMarketplaceDependency, duplicatedMarketplaceDependency]) as typeof allPlugins.filter
const params = createDefaultParams({ allPlugins })
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.plugins).toHaveLength(1)
expect(result.current.errorIndexes).toEqual([])
})
})
// ==================== Loaded All Data Notification ====================

View File

@ -0,0 +1,131 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../types'
import WorkflowChecklist from './index'
let mockChecklistItems = [
{
id: 'plugin-1',
type: BlockEnum.Tool,
title: 'Missing Plugin',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
},
{
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
isPluginMissing: false,
},
]
const mockHandleNodeSelect = vi.fn()
type PopoverProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: PopoverProps['onOpenChange']
vi.mock('reactflow', () => ({
useEdges: () => [],
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: () => [],
}))
vi.mock('../../hooks', () => ({
useChecklist: () => mockChecklistItems,
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('@/app/components/base/ui/popover', () => ({
Popover: ({ children, onOpenChange }: PopoverProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="popover">{children}</div>
},
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>,
}))
vi.mock('./plugin-group', () => ({
ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => <div data-testid="plugin-group">{items.map(item => item.title).join(',')}</div>,
}))
vi.mock('./node-group', () => ({
ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => (
<button data-testid={`node-group-${item.title}`} onClick={() => onItemClick(item)}>
{item.title}
</button>
),
}))
describe('WorkflowChecklist', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
mockChecklistItems = [
{
id: 'plugin-1',
type: BlockEnum.Tool,
title: 'Missing Plugin',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
},
{
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
isPluginMissing: false,
},
]
})
it('should split checklist items into plugin and node groups and delegate clicks to node selection by default', () => {
render(<WorkflowChecklist disabled={false} />)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByTestId('plugin-group')).toHaveTextContent('Missing Plugin')
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
})
it('should use the custom item click handler when provided', () => {
const onItemClick = vi.fn()
render(<WorkflowChecklist disabled={false} onItemClick={onItemClick} />)
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
})
it('should render the resolved state when there are no checklist warnings', () => {
mockChecklistItems = []
render(<WorkflowChecklist disabled={false} />)
expect(screen.getByText(/checklistResolved/i)).toBeInTheDocument()
})
it('should ignore popover open changes when the checklist is disabled', () => {
render(<WorkflowChecklist disabled={true} />)
latestOnOpenChange?.(true)
expect(screen.getByText('2').closest('button')).toBeDisabled()
})
})

View File

@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../types'
import { ChecklistNodeGroup } from './node-group'
vi.mock('../../block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
vi.mock('./item-indicator', () => ({
ItemIndicator: () => <div data-testid="item-indicator" />,
}))
const createItem = (overrides: Record<string, unknown> = {}) => ({
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
disableGoTo: false,
unConnected: false,
...overrides,
})
describe('ChecklistNodeGroup', () => {
it('should render errors and the connection warning, and allow navigation when go-to is enabled', () => {
const onItemClick = vi.fn()
render(
<ChecklistNodeGroup
item={createItem({ unConnected: true }) as never}
showGoTo={true}
onItemClick={onItemClick}
/>,
)
expect(screen.getByText('Needs configuration')).toBeInTheDocument()
expect(screen.getByText(/needConnectTip/i)).toBeInTheDocument()
expect(screen.getAllByText(/goToFix/i)).toHaveLength(2)
fireEvent.click(screen.getByText('Needs configuration'))
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
})
it('should not allow navigation when go-to is disabled', () => {
const onItemClick = vi.fn()
render(
<ChecklistNodeGroup
item={createItem({ disableGoTo: true }) as never}
showGoTo={true}
onItemClick={onItemClick}
/>,
)
fireEvent.click(screen.getByText('Needs configuration'))
expect(onItemClick).not.toHaveBeenCalled()
expect(screen.queryByText(/goToFix/i)).not.toBeInTheDocument()
})
})

View File

@ -76,4 +76,21 @@ describe('ChecklistPluginGroup', () => {
fireEvent.click(installButton)
expect(usePluginDependencyStore.getState().dependencies).toEqual([])
})
it('should omit the version when the marketplace identifier does not include one', () => {
renderInPopover([createChecklistItem({ pluginUniqueIdentifier: 'langgenius/test-plugin@sha256' })])
fireEvent.click(getInstallButton())
expect(usePluginDependencyStore.getState().dependencies).toEqual([
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'langgenius/test-plugin@sha256',
plugin_unique_identifier: 'langgenius/test-plugin@sha256',
version: undefined,
},
},
])
})
})

View File

@ -0,0 +1,150 @@
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'
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerWebhookRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerPluginRunInWorkflow = vi.fn()
const mockHandleWorkflowRunAllTriggersInWorkflow = vi.fn()
const mockHandleStopRun = vi.fn()
const mockNotify = vi.fn()
const mockTrackEvent = vi.fn()
let mockWarningNodes: Array<{ id: string }> = []
let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus }, task_id: string } | undefined
let mockIsListening = false
let mockDynamicOptions = [
{ type: TriggerType.UserInput, nodeId: 'start-node' },
]
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowStartRun: () => ({
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: mockHandleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: mockHandleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: mockHandleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: mockHandleWorkflowRunAllTriggersInWorkflow,
}),
useWorkflowRun: () => ({
handleStopRun: mockHandleStopRun,
}),
useWorkflowRunValidation: () => ({
warningNodes: mockWarningNodes,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) =>
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
}))
vi.mock('../hooks/use-dynamic-test-run-options', () => ({
useDynamicTestRunOptions: () => mockDynamicOptions,
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: () => <span data-testid="shortcuts-name">Shortcut</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
StopCircle: () => <span data-testid="stop-circle" />,
}))
vi.mock('./test-run-menu', async (importOriginal) => {
const actual = await importOriginal<typeof import('./test-run-menu')>()
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) => {
React.useImperativeHandle(ref, () => ({
toggle: vi.fn(),
}))
return (
<div>
<button data-testid="trigger-option" onClick={() => onSelect(options[0])}>
Trigger option
</button>
{children}
</div>
)
}),
}
})
describe('RunMode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWarningNodes = []
mockWorkflowRunningData = undefined
mockIsListening = false
mockDynamicOptions = [
{ type: TriggerType.UserInput, nodeId: 'start-node' },
]
})
it('should render the run trigger and start the workflow when a valid trigger is selected', () => {
render(<RunMode />)
expect(screen.getByText(/run/i)).toBeInTheDocument()
fireEvent.click(screen.getByTestId('trigger-option'))
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledTimes(1)
expect(mockTrackEvent).toHaveBeenCalledWith('app_start_action_time', { action_type: 'user_input' })
})
it('should show an error toast instead of running when the selected trigger has checklist warnings', () => {
mockWarningNodes = [{ id: 'start-node' }]
render(<RunMode />)
fireEvent.click(screen.getByTestId('trigger-option'))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.panel.checklistTip',
})
expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled()
})
it('should render the running state and stop the workflow when it is already running', () => {
mockWorkflowRunningData = {
result: { status: WorkflowRunningStatus.Running },
task_id: 'task-1',
}
render(<RunMode />)
expect(screen.getByText(/running/i)).toBeInTheDocument()
fireEvent.click(screen.getByTestId('stop-circle').closest('button') as HTMLButtonElement)
expect(mockHandleStopRun).toHaveBeenCalledWith('task-1')
})
it('should render the listening label when the workflow is listening', () => {
mockIsListening = true
render(<RunMode />)
expect(screen.getByText(/listening/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,253 @@
import type { CommonNodeType } from '../../types'
import { act } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useNodePluginInstallation } from '../use-node-plugin-installation'
const mockBuiltInTools = vi.fn()
const mockCustomTools = vi.fn()
const mockWorkflowTools = vi.fn()
const mockMcpTools = vi.fn()
const mockInvalidToolsByType = vi.fn()
const mockTriggerPlugins = vi.fn()
const mockInvalidateTriggers = vi.fn()
const mockInvalidDataSourceList = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled),
useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled),
useAllWorkflowTools: (enabled: boolean) => mockWorkflowTools(enabled),
useAllMCPTools: (enabled: boolean) => mockMcpTools(enabled),
useInvalidToolsByType: (providerType?: string) => mockInvalidToolsByType(providerType),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: (enabled: boolean) => mockTriggerPlugins(enabled),
useInvalidateAllTriggerPlugins: () => mockInvalidateTriggers,
}))
vi.mock('@/service/use-pipeline', () => ({
useInvalidDataSourceList: () => mockInvalidDataSourceList,
}))
const makeToolNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.Tool,
title: 'Tool node',
desc: '',
provider_type: CollectionType.builtIn,
provider_id: 'search',
provider_name: 'search',
plugin_id: 'plugin-search',
plugin_unique_identifier: 'plugin-search@1.0.0',
...overrides,
}) as CommonNodeType
const makeTriggerNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.TriggerPlugin,
title: 'Trigger node',
desc: '',
provider_id: 'trigger-provider',
provider_name: 'trigger-provider',
plugin_id: 'trigger-plugin',
plugin_unique_identifier: 'trigger-plugin@1.0.0',
...overrides,
}) as CommonNodeType
const makeDataSourceNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.DataSource,
title: 'Data source node',
desc: '',
provider_name: 'knowledge-provider',
plugin_id: 'knowledge-plugin',
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
...overrides,
}) as CommonNodeType
const matchedTool = {
plugin_id: 'plugin-search',
provider: 'search',
name: 'search',
plugin_unique_identifier: 'plugin-search@1.0.0',
}
const matchedTriggerProvider = {
id: 'trigger-provider',
name: 'trigger-provider',
plugin_id: 'trigger-plugin',
}
const matchedDataSource = {
provider: 'knowledge-provider',
plugin_id: 'knowledge-plugin',
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
}
describe('useNodePluginInstallation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: false })
mockCustomTools.mockReturnValue({ data: undefined, isLoading: false })
mockWorkflowTools.mockReturnValue({ data: undefined, isLoading: false })
mockMcpTools.mockReturnValue({ data: undefined, isLoading: false })
mockInvalidToolsByType.mockReturnValue(undefined)
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: false })
mockInvalidateTriggers.mockReset()
mockInvalidDataSourceList.mockReset()
})
it('should return the noop installation state for non plugin-dependent nodes', () => {
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation({
type: BlockEnum.LLM,
title: 'LLM',
desc: '',
} as CommonNodeType),
)
expect(result.current).toEqual({
isChecking: false,
isMissing: false,
uniqueIdentifier: undefined,
canInstall: false,
onInstallSuccess: expect.any(Function),
shouldDim: false,
})
})
it('should report loading and invalidate built-in tools while the collection is resolving', () => {
const invalidateTools = vi.fn()
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: true })
mockInvalidToolsByType.mockReturnValue(invalidateTools)
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeToolNode()))
expect(mockBuiltInTools).toHaveBeenCalledWith(true)
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('plugin-search@1.0.0')
expect(result.current.canInstall).toBe(true)
expect(result.current.shouldDim).toBe(true)
act(() => {
result.current.onInstallSuccess()
})
expect(invalidateTools).toHaveBeenCalled()
})
it.each([
[CollectionType.custom, mockCustomTools],
[CollectionType.workflow, mockWorkflowTools],
[CollectionType.mcp, mockMcpTools],
])('should resolve matched %s tool collections without dimming', (providerType, hookMock) => {
hookMock.mockReturnValue({ data: [matchedTool], isLoading: false })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeToolNode({ provider_type: providerType })),
)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(false)
expect(result.current.shouldDim).toBe(false)
})
it('should keep unknown tool collection types installable without collection state', () => {
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeToolNode({
provider_type: 'unknown' as CollectionType,
plugin_unique_identifier: undefined,
plugin_id: undefined,
provider_id: 'legacy-provider',
})),
)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('legacy-provider')
expect(result.current.canInstall).toBe(false)
expect(result.current.shouldDim).toBe(false)
})
it('should flag missing trigger plugins and invalidate trigger data after installation', () => {
mockTriggerPlugins.mockReturnValue({ data: [matchedTriggerProvider], isLoading: false })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeTriggerNode({
provider_id: 'missing-trigger',
provider_name: 'missing-trigger',
plugin_id: 'missing-trigger',
})),
)
expect(mockTriggerPlugins).toHaveBeenCalledWith(true)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(true)
expect(result.current.shouldDim).toBe(true)
act(() => {
result.current.onInstallSuccess()
})
expect(mockInvalidateTriggers).toHaveBeenCalled()
})
it('should treat the trigger plugin list as still loading when it has not resolved yet', () => {
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: true })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeTriggerNode({ plugin_unique_identifier: undefined, plugin_id: 'trigger-plugin' })),
)
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('trigger-plugin')
expect(result.current.canInstall).toBe(false)
expect(result.current.shouldDim).toBe(true)
})
it('should track missing and matched data source providers based on workflow store state', () => {
const missingRender = renderWorkflowHook(
() => useNodePluginInstallation(makeDataSourceNode({
provider_name: 'missing-provider',
plugin_id: 'missing-plugin',
plugin_unique_identifier: 'missing-plugin@1.0.0',
})),
{
initialStoreState: {
dataSourceList: [matchedDataSource] as never,
},
},
)
expect(missingRender.result.current.isChecking).toBe(false)
expect(missingRender.result.current.isMissing).toBe(true)
expect(missingRender.result.current.shouldDim).toBe(true)
const matchedRender = renderWorkflowHook(
() => useNodePluginInstallation(makeDataSourceNode()),
{
initialStoreState: {
dataSourceList: [matchedDataSource] as never,
},
},
)
expect(matchedRender.result.current.isMissing).toBe(false)
expect(matchedRender.result.current.shouldDim).toBe(false)
act(() => {
matchedRender.result.current.onInstallSuccess()
})
expect(mockInvalidDataSourceList).toHaveBeenCalled()
})
it('should keep data sources in checking state before the list is loaded', () => {
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeDataSourceNode()))
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.shouldDim).toBe(true)
})
})

View File

@ -0,0 +1,56 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Field from './field'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
describe('Field', () => {
it('should render subtitle styling, tooltip, operations, warning dot and required marker', () => {
const { container } = render(
<Field
title="Knowledge"
tooltip="tooltip text"
operations={<button type="button">operation</button>}
required
warningDot
isSubTitle
/>,
)
expect(screen.getByText('Knowledge')).toBeInTheDocument()
expect(screen.getByText('tooltip text')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'operation' })).toBeInTheDocument()
expect(screen.getByText('*')).toBeInTheDocument()
expect(container.querySelector('.system-xs-medium-uppercase')).not.toBeNull()
expect(container.querySelector('.bg-text-warning-secondary')).not.toBeNull()
})
it('should toggle folded children when supportFold is enabled', () => {
const { container } = render(
<Field title="Foldable" supportFold>
<div>folded content</div>
</Field>,
)
expect(screen.queryByText('folded content')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!)
expect(screen.getByText('folded content')).toBeInTheDocument()
expect(container.querySelector('svg')).toHaveStyle({ transform: 'rotate(0deg)' })
fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!)
expect(screen.queryByText('folded content')).not.toBeInTheDocument()
})
it('should render inline children without folding support', () => {
const { container } = render(
<Field title="Inline" inline>
<div>always visible</div>
</Field>,
)
expect(screen.getByText('always visible')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('flex')
})
})

View File

@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { FieldTitle } from './field-title'
vi.mock('@/app/components/base/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('FieldTitle', () => {
it('should render title, subtitle, operation, tooltip and warning dot', () => {
render(
<FieldTitle
title="Embedding"
subTitle={<div>subtitle</div>}
operation={<button type="button">action</button>}
tooltip="Tooltip copy"
warningDot
/>,
)
expect(screen.getByText('Embedding')).toBeInTheDocument()
expect(screen.getByText('subtitle')).toBeInTheDocument()
expect(screen.getByText('Tooltip copy')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'action' })).toBeInTheDocument()
expect(document.querySelector('.bg-text-warning-secondary')).not.toBeNull()
})
it('should toggle local collapsed state and notify onCollapse when enabled', () => {
const onCollapse = vi.fn()
const { container } = render(
<FieldTitle
title="Models"
showArrow
onCollapse={onCollapse}
/>,
)
const header = screen.getByText('Models').closest('.group\\/collapse')
const arrow = container.querySelector('[aria-hidden="true"]')
expect(arrow).toHaveClass('rotate-[270deg]')
fireEvent.click(header!)
expect(onCollapse).toHaveBeenCalledWith(false)
expect(arrow).not.toHaveClass('rotate-[270deg]')
})
it('should respect controlled collapsed state and ignore clicks when disabled', () => {
const onCollapse = vi.fn()
const { container } = render(
<FieldTitle
title="Controlled"
showArrow
collapsed={false}
disabled
onCollapse={onCollapse}
/>,
)
fireEvent.click(screen.getByText('Controlled').closest('.group\\/collapse')!)
expect(onCollapse).not.toHaveBeenCalled()
expect(container.querySelector('[aria-hidden="true"]')).not.toHaveClass('rotate-[270deg]')
})
})

View File

@ -0,0 +1,130 @@
import type { CommonNodeType } from '../../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, NodeRunningStatus } from '../../../types'
import NodeControl from './node-control'
const mockHandleNodeSelect = vi.fn()
const mockSetInitShowLastRunTab = vi.fn()
const mockSetPendingSingleRun = vi.fn()
const mockCanRunBySingle = vi.fn(() => true)
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
),
}))
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
Stop: ({ className }: { className?: string }) => <div data-testid="stop-icon" className={className} />,
}))
vi.mock('../../../hooks', () => ({
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setInitShowLastRunTab: mockSetInitShowLastRunTab,
setPendingSingleRun: mockSetPendingSingleRun,
}),
}),
}))
vi.mock('../../../utils', () => ({
canRunBySingle: mockCanRunBySingle,
}))
vi.mock('./panel-operator', () => ({
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
<>
<button type="button" onClick={() => onOpenChange(true)}>open panel</button>
<button type="button" onClick={() => onOpenChange(false)}>close panel</button>
</>
),
}))
const makeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
type: BlockEnum.Code,
title: 'Node',
desc: '',
selected: false,
_singleRunningStatus: undefined,
isInIteration: false,
isInLoop: false,
...overrides,
})
describe('NodeControl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanRunBySingle.mockReturnValue(true)
})
it('should trigger a single run and show the hover control when plugins are not locked', () => {
const { container } = render(
<NodeControl
id="node-1"
data={makeData()}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('group-hover:flex')
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'panel.runThisStep')
fireEvent.click(screen.getByTestId('tooltip').parentElement!)
expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true)
expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-1', action: 'run' })
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
})
it('should render the stop action, keep locked controls hidden by default, and stay open when panel operator opens', () => {
const { container } = render(
<NodeControl
id="node-2"
pluginInstallLocked
data={makeData({
selected: true,
_singleRunningStatus: NodeRunningStatus.Running,
isInIteration: true,
})}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).not.toContain('group-hover:flex')
expect(wrapper.className).toContain('!flex')
expect(screen.getByTestId('stop-icon')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('stop-icon').parentElement!)
expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-2', action: 'stop' })
fireEvent.click(screen.getByRole('button', { name: 'open panel' }))
expect(wrapper.className).toContain('!flex')
})
it('should hide the run control when single-node execution is not supported', () => {
mockCanRunBySingle.mockReturnValue(false)
render(
<NodeControl
id="node-3"
data={makeData()}
/>,
)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { ChunkStructureEnum } from '../../types'
import ChunkStructure from './index'
const mockUseChunkStructure = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { title: string, warningDot?: boolean, operation?: ReactNode } }) => (
<div data-testid="field" data-warning-dot={String(!!fieldTitleProps.warningDot)}>
<div>{fieldTitleProps.title}</div>
{fieldTitleProps.operation}
{children}
</div>
),
}))
vi.mock('./hooks', () => ({
useChunkStructure: mockUseChunkStructure,
}))
vi.mock('../option-card', () => ({
default: ({ title }: { title: string }) => <div data-testid="option-card">{title}</div>,
}))
vi.mock('./selector', () => ({
default: ({ trigger, value }: { trigger?: ReactNode, value?: string }) => (
<div data-testid="selector">
{value ?? 'no-value'}
{trigger}
</div>
),
}))
vi.mock('./instruction', () => ({
default: ({ className }: { className?: string }) => <div data-testid="instruction" className={className}>Instruction</div>,
}))
describe('ChunkStructure', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseChunkStructure.mockReturnValue({
options: [{ value: ChunkStructureEnum.general, label: 'General' }],
optionMap: {
[ChunkStructureEnum.general]: {
title: 'General Chunk Structure',
},
},
})
})
it('should render the selected option and warning dot metadata when a chunk structure is chosen', () => {
render(
<ChunkStructure
chunkStructure={ChunkStructureEnum.general}
warningDot
onChunkStructureChange={vi.fn()}
/>,
)
expect(screen.getByTestId('field')).toHaveAttribute('data-warning-dot', 'true')
expect(screen.getByTestId('selector')).toHaveTextContent(ChunkStructureEnum.general)
expect(screen.getByTestId('option-card')).toHaveTextContent('General Chunk Structure')
expect(screen.queryByTestId('instruction')).not.toBeInTheDocument()
})
it('should render the add trigger and instruction when no chunk structure is selected', () => {
render(
<ChunkStructure
onChunkStructureChange={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /chooseChunkStructure/i })).toBeInTheDocument()
expect(screen.getByTestId('instruction')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,62 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import EmbeddingModel from './embedding-model'
const mockUseModelList = vi.hoisted(() => vi.fn())
const mockModelSelector = vi.hoisted(() => vi.fn(() => <div data-testid="model-selector">selector</div>))
vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { warningDot?: boolean } }) => (
<div data-testid="field" data-warning-dot={String(!!fieldTitleProps.warningDot)}>
{children}
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: mockUseModelList,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: mockModelSelector,
}))
describe('EmbeddingModel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseModelList.mockReturnValue({ data: [{ provider: 'openai', model: 'text-embedding-3-large' }] })
})
it('should pass the selected model configuration and warning state to the selector field', () => {
const onEmbeddingModelChange = vi.fn()
render(
<EmbeddingModel
embeddingModel="text-embedding-3-large"
embeddingModelProvider="openai"
warningDot
onEmbeddingModelChange={onEmbeddingModelChange}
/>,
)
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding)
expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({
defaultModel: {
provider: 'openai',
model: 'text-embedding-3-large',
},
modelList: [{ provider: 'openai', model: 'text-embedding-3-large' }],
readonly: false,
showDeprecatedWarnIcon: true,
}), undefined)
})
it('should pass an undefined default model when the embedding model is incomplete', () => {
render(<EmbeddingModel embeddingModel="text-embedding-3-large" />)
expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({
defaultModel: undefined,
}), undefined)
})
})

View File

@ -0,0 +1,74 @@
import type { KnowledgeBaseNodeType } from './types'
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import nodeDefault from './default'
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
const t = (key: string) => key
const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => [{
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [{
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status,
model_properties: {},
load_balancing_enabled: false,
}],
status,
}]
const makeEmbeddingProviderModelList = (status: ModelStatusEnum): ModelItem[] => [{
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status,
model_properties: {},
load_balancing_enabled: false,
}]
const createPayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => ({
...nodeDefault.defaultValue,
index_chunk_variable_selector: ['chunks', 'results'],
chunk_structure: ChunkStructureEnum.general,
indexing_technique: IndexMethodEnum.QUALIFIED,
embedding_model: 'text-embedding-3-large',
embedding_model_provider: 'openai',
retrieval_model: {
...nodeDefault.defaultValue.retrieval_model,
search_method: RetrievalSearchMethodEnum.semantic,
},
_embeddingModelList: makeEmbeddingModelList(ModelStatusEnum.active),
_embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.active),
_rerankModelList: [],
...overrides,
}) as KnowledgeBaseNodeType
describe('knowledge-base default node validation', () => {
it('should return an invalid result when the payload has a validation issue', () => {
const result = nodeDefault.checkValid(createPayload({ chunk_structure: undefined }), t)
expect(result).toEqual({
isValid: false,
errorMessage: 'nodes.knowledgeBase.chunkIsRequired',
})
})
it('should return a valid result when the payload is complete', () => {
const result = nodeDefault.checkValid(createPayload(), t)
expect(result).toEqual({
isValid: true,
errorMessage: '',
})
})
})

View File

@ -158,4 +158,76 @@ describe('KnowledgeBaseNode', () => {
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
})
})
describe('Validation warnings', () => {
it('should render a warning banner when chunk structure is missing', () => {
render(
<Node
id="knowledge-base-1"
data={createNodeData({
chunk_structure: undefined,
})}
/>,
)
expect(screen.getByText(/chunkIsRequired/i)).toBeInTheDocument()
})
it('should render a warning value for the chunks input row when no chunk variable is selected', () => {
render(
<Node
id="knowledge-base-1"
data={createNodeData({
index_chunk_variable_selector: [],
})}
/>,
)
expect(screen.getByText(/chunksVariableIsRequired/i)).toBeInTheDocument()
})
it('should render a warning value for retrieval settings when reranking is incomplete', () => {
mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => {
if (modelType === ModelTypeEnum.textEmbedding) {
return {
data: [{
provider: 'openai',
models: [createModelItem()],
}],
}
}
return { data: [] }
})
render(
<Node
id="knowledge-base-1"
data={createNodeData({
retrieval_model: {
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
search_method: RetrievalSearchMethodEnum.semantic,
reranking_enable: true,
},
})}
/>,
)
expect(screen.getByText(/rerankingModelIsRequired/i)).toBeInTheDocument()
})
it('should hide the embedding model row when the index method is not qualified', () => {
render(
<Node
id="knowledge-base-1"
data={createNodeData({
indexing_technique: IndexMethodEnum.ECONOMICAL,
})}
/>,
)
expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,198 @@
import type { ReactNode } from 'react'
import type { PanelProps } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Panel from './panel'
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
const mockUseModelList = vi.hoisted(() => vi.fn())
const mockUseQuery = vi.hoisted(() => vi.fn())
const mockUseEmbeddingModelStatus = vi.hoisted(() => vi.fn())
const mockChunkStructure = vi.hoisted(() => vi.fn(() => <div data-testid="chunk-structure" />))
const mockEmbeddingModel = vi.hoisted(() => vi.fn(() => <div data-testid="embedding-model" />))
const mockSummaryIndexSetting = vi.hoisted(() => vi.fn(() => <div data-testid="summary-index-setting" />))
const mockQueryOptions = vi.hoisted(() => vi.fn((options: unknown) => options))
vi.mock('@tanstack/react-query', () => ({
useQuery: mockUseQuery,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryOptions: mockQueryOptions,
},
},
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: mockUseModelList,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
}))
vi.mock('./hooks/use-config', () => ({
useConfig: () => ({
handleChunkStructureChange: vi.fn(),
handleIndexMethodChange: vi.fn(),
handleKeywordNumberChange: vi.fn(),
handleEmbeddingModelChange: vi.fn(),
handleRetrievalSearchMethodChange: vi.fn(),
handleHybridSearchModeChange: vi.fn(),
handleRerankingModelEnabledChange: vi.fn(),
handleWeighedScoreChange: vi.fn(),
handleRerankingModelChange: vi.fn(),
handleTopKChange: vi.fn(),
handleScoreThresholdChange: vi.fn(),
handleScoreThresholdEnabledChange: vi.fn(),
handleInputVariableChange: vi.fn(),
handleSummaryIndexSettingChange: vi.fn(),
}),
}))
vi.mock('./hooks/use-embedding-model-status', () => ({
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
}))
vi.mock('@/app/components/datasets/settings/utils', () => ({
checkShowMultiModalTip: () => false,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CE_EDITION: true,
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
Group: ({ children }: { children: ReactNode }) => <div data-testid="group">{children}</div>,
BoxGroup: ({ children }: { children: ReactNode }) => <div data-testid="box-group">{children}</div>,
BoxGroupField: ({ children, fieldProps }: { children: ReactNode, fieldProps: { fieldTitleProps: { warningDot?: boolean } } }) => (
<div data-testid="box-group-field" data-warning-dot={String(!!fieldProps.fieldTitleProps.warningDot)}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: () => <div data-testid="var-reference-picker" />,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div data-testid="split" />,
}))
vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
default: mockSummaryIndexSetting,
}))
vi.mock('./components/chunk-structure', () => ({
default: mockChunkStructure,
}))
vi.mock('./components/index-method', () => ({
default: () => <div data-testid="index-method" />,
}))
vi.mock('./components/embedding-model', () => ({
default: mockEmbeddingModel,
}))
vi.mock('./components/retrieval-setting', () => ({
default: () => <div data-testid="retrieval-setting" />,
}))
const createData = (overrides: Record<string, unknown> = {}) => ({
index_chunk_variable_selector: ['chunks', 'results'],
chunk_structure: ChunkStructureEnum.general,
indexing_technique: IndexMethodEnum.QUALIFIED,
embedding_model: 'text-embedding-3-large',
embedding_model_provider: 'openai',
keyword_number: 10,
retrieval_model: {
search_method: RetrievalSearchMethodEnum.semantic,
reranking_enable: false,
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
},
...overrides,
})
const panelProps: PanelProps = {
getInputVars: () => [],
toVarInputs: () => [],
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: undefined,
}
describe('KnowledgeBasePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockReturnValue({ data: undefined })
mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => {
if (modelType === ModelTypeEnum.textEmbedding) {
return {
data: [{
provider: 'openai',
models: [{ model: 'text-embedding-3-large' }],
}],
}
}
return { data: [] }
})
mockUseEmbeddingModelStatus.mockReturnValue({ status: 'active' })
})
it('should show a warning dot on chunk structure and skip nested sections when chunk structure is missing', () => {
render(<Panel id="knowledge-base-1" data={createData({ chunk_structure: undefined }) as never} panelProps={panelProps} />)
expect(mockChunkStructure).toHaveBeenCalledWith(expect.objectContaining({
warningDot: true,
}), undefined)
expect(screen.queryByTestId('box-group-field')).not.toBeInTheDocument()
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
enabled: true,
}))
})
it('should pass warning dots and render summary settings when the qualified configuration needs attention', () => {
mockUseEmbeddingModelStatus.mockReturnValue({ status: 'disabled' })
render(<Panel id="knowledge-base-1" data={createData({ index_chunk_variable_selector: [] }) as never} panelProps={panelProps} />)
expect(screen.getByTestId('box-group-field')).toHaveAttribute('data-warning-dot', 'true')
expect(mockEmbeddingModel).toHaveBeenCalledWith(expect.objectContaining({
warningDot: true,
}), undefined)
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
input: { params: { provider: 'openai' } },
enabled: true,
}))
expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument()
})
it('should hide embedding and summary settings for non-qualified index methods', () => {
render(
<Panel
id="knowledge-base-1"
data={createData({ indexing_technique: IndexMethodEnum.ECONOMICAL }) as never}
panelProps={panelProps}
/>,
)
expect(screen.queryByTestId('embedding-model')).not.toBeInTheDocument()
expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument()
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
enabled: false,
}))
})
})

View File

@ -12,6 +12,9 @@ import {
} from './types'
import {
getKnowledgeBaseValidationIssue,
getKnowledgeBaseValidationMessage,
isHighQualitySearchMethod,
isKnowledgeBaseEmbeddingIssue,
KnowledgeBaseValidationIssueCode,
} from './utils'
@ -69,6 +72,13 @@ const makePayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeB
}
describe('knowledge-base validation issue', () => {
it('identifies high quality retrieval methods', () => {
expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.semantic)).toBe(true)
expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.hybrid)).toBe(true)
expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.fullText)).toBe(true)
expect(isHighQualitySearchMethod('unknown-method' as RetrievalSearchMethodEnum)).toBe(false)
})
it('returns chunk structure issue when chunk structure is missing', () => {
const issue = getKnowledgeBaseValidationIssue(makePayload({ chunk_structure: undefined }))
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.chunkStructureRequired)
@ -123,4 +133,94 @@ describe('knowledge-base validation issue', () => {
)
expect(issue).toBeNull()
})
it('returns embedding-model-not-configured when the qualified index is missing provider details', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ embedding_model: undefined }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
})
it('maps no-permission embedding models to incompatible', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noPermission) }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
})
it('returns retrieval-setting-required when retrieval search method is missing', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ retrieval_model: undefined as never }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.retrievalSettingRequired)
})
it('returns reranking-model-required when reranking is enabled without a model', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({
retrieval_model: {
...makePayload().retrieval_model,
reranking_enable: true,
},
}),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.rerankingModelRequired)
})
it('returns reranking-model-invalid when the configured reranking model is unavailable', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({
retrieval_model: {
...makePayload().retrieval_model,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'missing-provider',
reranking_model_name: 'missing-model',
},
},
}),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.rerankingModelInvalid)
})
})
describe('knowledge-base validation messaging', () => {
const t = (key: string) => key
it.each([
[KnowledgeBaseValidationIssueCode.chunkStructureRequired, 'nodes.knowledgeBase.chunkIsRequired'],
[KnowledgeBaseValidationIssueCode.chunksVariableRequired, 'nodes.knowledgeBase.chunksVariableIsRequired'],
[KnowledgeBaseValidationIssueCode.indexMethodRequired, 'nodes.knowledgeBase.indexMethodIsRequired'],
[KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured, 'nodes.knowledgeBase.embeddingModelNotConfigured'],
[KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired, 'modelProvider.selector.configureRequired'],
[KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable, 'modelProvider.selector.apiKeyUnavailable'],
[KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted, 'modelProvider.selector.creditsExhausted'],
[KnowledgeBaseValidationIssueCode.embeddingModelDisabled, 'modelProvider.selector.disabled'],
[KnowledgeBaseValidationIssueCode.embeddingModelIncompatible, 'modelProvider.selector.incompatible'],
[KnowledgeBaseValidationIssueCode.retrievalSettingRequired, 'nodes.knowledgeBase.retrievalSettingIsRequired'],
[KnowledgeBaseValidationIssueCode.rerankingModelRequired, 'nodes.knowledgeBase.rerankingModelIsRequired'],
[KnowledgeBaseValidationIssueCode.rerankingModelInvalid, 'nodes.knowledgeBase.rerankingModelIsInvalid'],
] as const)('maps %s to the expected translation key', (code, expectedKey) => {
expect(getKnowledgeBaseValidationMessage({ code }, t as never)).toBe(expectedKey)
})
it('returns an empty string when there is no issue', () => {
expect(getKnowledgeBaseValidationMessage(undefined, t as never)).toBe('')
})
})
describe('isKnowledgeBaseEmbeddingIssue', () => {
it('returns true for embedding-related issues', () => {
expect(isKnowledgeBaseEmbeddingIssue({ code: KnowledgeBaseValidationIssueCode.embeddingModelDisabled })).toBe(true)
})
it('returns false for non-embedding issues and missing values', () => {
expect(isKnowledgeBaseEmbeddingIssue({ code: KnowledgeBaseValidationIssueCode.rerankingModelInvalid })).toBe(false)
expect(isKnowledgeBaseEmbeddingIssue(undefined)).toBe(false)
})
})

View File

@ -0,0 +1,47 @@
import type { LLMNodeType } from './types'
import { AppModeEnum } from '@/types/app'
import { EditionType, PromptRole } from '../../types'
import nodeDefault from './default'
const t = (key: string) => key
const createPayload = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({
...nodeDefault.defaultValue,
model: {
...nodeDefault.defaultValue.model,
provider: 'langgenius/openai/gpt-4.1',
mode: AppModeEnum.CHAT,
},
prompt_template: [{
role: PromptRole.system,
text: 'You are helpful.',
edition_type: EditionType.basic,
}],
...overrides,
}) as LLMNodeType
describe('llm default node validation', () => {
it('should require a model provider before validating the prompt', () => {
const result = nodeDefault.checkValid(createPayload({
model: {
...nodeDefault.defaultValue.model,
provider: '',
name: 'gpt-4.1',
mode: AppModeEnum.CHAT,
completion_params: {
temperature: 0.7,
},
},
}), t)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('errorMsg.fieldRequired')
})
it('should return a valid result when the provider and prompt are configured', () => {
const result = nodeDefault.checkValid(createPayload(), t)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})

View File

@ -0,0 +1,43 @@
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from './utils'
describe('llm utils', () => {
describe('getLLMModelIssue', () => {
it('returns provider-required when the model provider is missing', () => {
expect(getLLMModelIssue({ modelProvider: undefined })).toBe(LLMModelIssueCode.providerRequired)
})
it('returns provider-plugin-unavailable when the provider plugin is not installed', () => {
expect(getLLMModelIssue({
modelProvider: 'langgenius/openai/gpt-4.1',
isModelProviderInstalled: false,
})).toBe(LLMModelIssueCode.providerPluginUnavailable)
})
it('returns null when the provider is present and installed', () => {
expect(getLLMModelIssue({
modelProvider: 'langgenius/openai/gpt-4.1',
isModelProviderInstalled: true,
})).toBeNull()
})
})
describe('isLLMModelProviderInstalled', () => {
it('returns true when the model provider is missing', () => {
expect(isLLMModelProviderInstalled(undefined, new Set())).toBe(true)
})
it('matches installed plugin ids using the provider plugin prefix', () => {
expect(isLLMModelProviderInstalled(
'langgenius/openai/gpt-4.1',
new Set(['langgenius/openai']),
)).toBe(true)
})
it('returns false when the provider plugin id is not installed', () => {
expect(isLLMModelProviderInstalled(
'langgenius/openai/gpt-4.1',
new Set(['langgenius/anthropic']),
)).toBe(false)
})
})
})

View File

@ -146,6 +146,18 @@ describe('plugin install check', () => {
expect(isNodePluginMissing(node, { triggerPlugins: [createTriggerProvider()] })).toBe(true)
})
it('should keep trigger plugin nodes installable when the provider list has not loaded yet', () => {
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: undefined })).toBe(false)
})
it('should report missing data source plugins when the list is loaded but unmatched', () => {
const node = {
type: BlockEnum.DataSource,
@ -158,6 +170,18 @@ describe('plugin install check', () => {
expect(isNodePluginMissing(node, { dataSourceList: [createTool()] })).toBe(true)
})
it('should keep data source nodes installable when the list has not loaded yet', () => {
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: undefined })).toBe(false)
})
it('should return false for unsupported node types', () => {
const node = {
type: BlockEnum.LLM,