mirror of
https://github.com/langgenius/dify.git
synced 2026-04-24 21:05:48 +08:00
test: add coverage for model provider and workflow edge cases
This commit is contained in:
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
57
web/app/components/base/tag-input/__tests__/interop.spec.tsx
Normal file
57
web/app/components/base/tag-input/__tests__/interop.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
93
web/app/components/billing/pricing/__tests__/dialog.spec.tsx
Normal file
93
web/app/components/billing/pricing/__tests__/dialog.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()),
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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', '')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
128
web/app/components/plugins/hooks.spec.ts
Normal file
128
web/app/components/plugins/hooks.spec.ts
Normal 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],
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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 ====================
|
||||
|
||||
131
web/app/components/workflow/header/checklist/index.spec.tsx
Normal file
131
web/app/components/workflow/header/checklist/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
150
web/app/components/workflow/header/run-mode.spec.tsx
Normal file
150
web/app/components/workflow/header/run-mode.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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]')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
198
web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx
Normal file
198
web/app/components/workflow/nodes/knowledge-base/panel.spec.tsx
Normal 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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
47
web/app/components/workflow/nodes/llm/default.spec.ts
Normal file
47
web/app/components/workflow/nodes/llm/default.spec.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
43
web/app/components/workflow/nodes/llm/utils.spec.ts
Normal file
43
web/app/components/workflow/nodes/llm/utils.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user