test: add unit tests for access control components to enhance coverage and reliability (#34722)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Coding On Star 2026-04-08 16:50:57 +08:00 committed by GitHub
parent 0e0bb3582f
commit 9948a51b14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
109 changed files with 8876 additions and 270 deletions

View File

@ -0,0 +1,10 @@
import { render, screen } from '@testing-library/react'
import HitHistoryNoData from '../hit-history-no-data'
describe('HitHistoryNoData', () => {
it('should render the empty history message', () => {
render(<HitHistoryNoData />)
expect(screen.getByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,32 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import AccessControlDialog from '../access-control-dialog'
describe('AccessControlDialog', () => {
it('should render dialog content when visible', () => {
render(
<AccessControlDialog show className="custom-dialog">
<div>Dialog Content</div>
</AccessControlDialog>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Dialog Content')).toBeInTheDocument()
})
it('should trigger onClose when clicking the close control', async () => {
const onClose = vi.fn()
render(
<AccessControlDialog show onClose={onClose}>
<div>Dialog Content</div>
</AccessControlDialog>,
)
const closeButton = document.body.querySelector('div.absolute.right-5.top-5') as HTMLElement
fireEvent.click(closeButton)
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,45 @@
import { fireEvent, render, screen } from '@testing-library/react'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import AccessControlItem from '../access-control-item'
describe('AccessControlItem', () => {
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.PUBLIC,
selectedGroupsForBreadcrumb: [],
})
})
it('should update current menu when selecting a different access type', () => {
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
fireEvent.click(option)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
})
it('should keep the selected state for the active access type', () => {
useAccessControlStore.setState({
currentMenu: AccessMode.ORGANIZATION,
})
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option).toHaveClass('border-components-option-card-option-selected-border')
})
})

View File

@ -0,0 +1,130 @@
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useAccessControlStore from '@/context/access-control-store'
import { SubjectType } from '@/models/access-control'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
const mockUseSearchForWhiteListCandidates = vi.fn()
const intersectionObserverMocks = vi.hoisted(() => ({
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (value: { userProfile: { email: string } }) => T) => selector({
userProfile: {
email: 'member@example.com',
},
}),
}))
vi.mock('@/service/access-control', () => ({
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
}))
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
id: 'group-1',
name: 'Group One',
groupSize: 5,
...overrides,
} as AccessControlGroup)
const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({
id: 'member-1',
name: 'Member One',
email: 'member@example.com',
avatar: '',
avatarUrl: '',
...overrides,
} as AccessControlAccount)
describe('AddMemberOrGroupDialog', () => {
const baseGroup = createGroup()
const baseMember = createMember()
const groupSubject: Subject = {
subjectId: baseGroup.id,
subjectType: SubjectType.GROUP,
groupData: baseGroup,
} as Subject
const memberSubject: Subject = {
subjectId: baseMember.id,
subjectType: SubjectType.ACCOUNT,
accountData: baseMember,
} as Subject
beforeAll(() => {
class MockIntersectionObserver {
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
intersectionObserverMocks.callback = callback
}
observe = vi.fn(() => undefined)
disconnect = vi.fn(() => undefined)
unobserve = vi.fn(() => undefined)
}
// @ts-expect-error test DOM typings do not guarantee IntersectionObserver here
globalThis.IntersectionObserver = MockIntersectionObserver
})
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: 'app-1',
specificGroups: [],
specificMembers: [],
currentMenu: SubjectType.GROUP as never,
selectedGroupsForBreadcrumb: [],
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: {
pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }],
},
})
})
it('should open the search popover and display candidates', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument()
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
it('should allow expanding groups and selecting members', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement
fireEvent.click(memberCheckbox)
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
it('should show the empty state when no candidates are returned', async () => {
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,121 @@
/* eslint-disable ts/no-explicit-any */
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { toast } from '@/app/components/base/ui/toast'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import AccessControl from '../index'
const mockMutateAsync = vi.fn()
const mockUseUpdateAccessMode = vi.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
let mockWebappAuth = {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
}
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: typeof mockWebappAuth } }) => unknown) => selector({
systemFeatures: {
webapp_auth: mockWebappAuth,
},
}),
}))
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
describe('AccessControl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWebappAuth = {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
}
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockMutateAsync.mockResolvedValue(undefined)
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [],
members: [],
},
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
})
})
it('should initialize menu from the app and update access mode on confirm', async () => {
const onClose = vi.fn()
const onConfirm = vi.fn()
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const app = {
id: 'app-id-1',
access_mode: AccessMode.PUBLIC,
} as App
render(
<AccessControl
app={app}
onClose={onClose}
onConfirm={onConfirm}
/>,
)
await waitFor(() => {
expect(useAccessControlStore.getState().appId).toBe(app.id)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
appId: app.id,
accessMode: AccessMode.PUBLIC,
})
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
it('should show the external-members option when SSO tip is visible', () => {
mockWebappAuth = {
enabled: false,
allow_sso: false,
allow_email_password_login: false,
allow_email_code_login: false,
}
render(
<AccessControl
app={{ id: 'app-id-2', access_mode: AccessMode.PUBLIC } as App}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,97 @@
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockUseAppWhiteListSubjects = vi.fn()
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
}))
vi.mock('../add-member-or-group-pop', () => ({
default: () => <div data-testid="add-member-or-group-dialog" />,
}))
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
id: 'group-1',
name: 'Group One',
groupSize: 5,
...overrides,
} as AccessControlGroup)
const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({
id: 'member-1',
name: 'Member One',
email: 'member@example.com',
avatar: '',
avatarUrl: '',
...overrides,
} as AccessControlAccount)
describe('SpecificGroupsOrMembers', () => {
const baseGroup = createGroup()
const baseMember = createMember()
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [baseGroup],
members: [baseMember],
},
})
})
it('should render the collapsed row when not in specific mode', () => {
useAccessControlStore.setState({
currentMenu: AccessMode.ORGANIZATION,
})
render(<SpecificGroupsOrMembers />)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument()
})
it('should show loading while whitelist subjects are pending', async () => {
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
const { container } = render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
})
it('should render fetched groups and members and support removal', async () => {
useAccessControlStore.setState({ appId: 'app-1' })
render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
const groupRemove = screen.getByText(baseGroup.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
fireEvent.click(groupRemove)
expect(useAccessControlStore.getState().specificGroups).toEqual([])
const memberRemove = screen.getByText(baseMember.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
fireEvent.click(memberRemove)
expect(useAccessControlStore.getState().specificMembers).toEqual([])
})
})

View File

@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import InputTypeIcon from '../input-type-icon'
const mockInputVarTypeIcon = vi.fn(({ type, className }: { type: InputVarType, className?: string }) => (
<div data-testid="input-var-type-icon" data-type={type} className={className} />
))
vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({
default: (props: { type: InputVarType, className?: string }) => mockInputVarTypeIcon(props),
}))
describe('InputTypeIcon', () => {
it('should map string variables to the workflow text-input icon', () => {
render(<InputTypeIcon type="string" className="marker" />)
expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.textInput)
expect(screen.getByTestId('input-var-type-icon')).toHaveClass('marker')
})
it('should map select variables to the workflow select icon', () => {
render(<InputTypeIcon type="select" className="marker" />)
expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.select)
})
})

View File

@ -0,0 +1,19 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ModalFoot from '../modal-foot'
describe('ModalFoot', () => {
it('should trigger cancel and confirm callbacks', () => {
const onCancel = vi.fn()
const onConfirm = vi.fn()
render(
<ModalFoot onCancel={onCancel} onConfirm={onConfirm} />,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SelectVarType from '../select-var-type'
describe('SelectVarType', () => {
it('should open the menu and return the selected variable type', () => {
const onChange = vi.fn()
render(<SelectVarType onChange={onChange} />)
fireEvent.click(screen.getByText('common.operation.add'))
fireEvent.click(screen.getByText('appDebug.variableConfig.checkbox'))
expect(onChange).toHaveBeenCalledWith('checkbox')
expect(screen.queryByText('appDebug.variableConfig.checkbox')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,46 @@
import { fireEvent, render, screen } from '@testing-library/react'
import VarItem from '../var-item'
describe('VarItem', () => {
it('should render variable metadata and allow editing', () => {
const onEdit = vi.fn()
const onRemove = vi.fn()
const { container } = render(
<VarItem
canDrag
name="api_key"
label="API Key"
required
type="string"
onEdit={onEdit}
onRemove={onRemove}
/>,
)
expect(screen.getByTitle('api_key · API Key')).toBeInTheDocument()
expect(screen.getByText('required')).toBeInTheDocument()
const editButton = container.querySelector('.mr-1.flex.h-6.w-6') as HTMLElement
fireEvent.click(editButton)
expect(onEdit).toHaveBeenCalledTimes(1)
})
it('should call remove when clicking the delete action', () => {
const onRemove = vi.fn()
render(
<VarItem
name="region"
label="Region"
required={false}
type="select"
onEdit={vi.fn()}
onRemove={onRemove}
/>,
)
fireEvent.click(screen.getByTestId('var-item-delete-btn'))
expect(onRemove).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,23 @@
import { jsonConfigPlaceHolder } from '../config'
describe('config modal placeholder config', () => {
it('should contain a valid object schema example', () => {
const parsed = JSON.parse(jsonConfigPlaceHolder) as {
type: string
properties: {
foo: { type: string }
bar: {
type: string
properties: {
sub: { type: string }
}
}
}
}
expect(parsed.type).toBe('object')
expect(parsed.properties.foo.type).toBe('string')
expect(parsed.properties.bar.type).toBe('object')
expect(parsed.properties.bar.properties.sub.type).toBe('number')
})
})

View File

@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react'
import Field from '../field'
describe('ConfigModal Field', () => {
it('should render the title and children', () => {
render(
<Field title="Field title">
<input aria-label="field-input" />
</Field>,
)
expect(screen.getByText('Field title')).toBeInTheDocument()
expect(screen.getByLabelText('field-input')).toBeInTheDocument()
})
it('should render the optional hint when requested', () => {
render(
<Field title="Optional field" isOptional>
<input aria-label="optional-field-input" />
</Field>,
)
expect(screen.getByText(/\(appDebug\.variableConfig\.optional\)/)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,74 @@
import type { FeatureStoreState } from '@/app/components/base/features/store'
import type { FileUpload } from '@/app/components/base/features/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Resolution, TransferMethod } from '@/types/app'
import ParamConfigContent from '../param-config-content'
const mockUseFeatures = vi.fn()
const mockUseFeaturesStore = vi.fn()
const mockSetFeatures = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => {
const file: FileUpload = {
enabled: true,
allowed_file_types: [],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
image: {
enabled: true,
detail: Resolution.low,
number_limits: 3,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
},
...fileOverrides,
}
const featureStoreState = {
features: { file },
setFeatures: mockSetFeatures,
showFeaturesModal: false,
setShowFeaturesModal: vi.fn(),
} as unknown as FeatureStoreState
mockUseFeatures.mockImplementation(selector => selector(featureStoreState))
mockUseFeaturesStore.mockReturnValue({
getState: () => featureStoreState,
})
}
const getUpdatedFile = () => {
expect(mockSetFeatures).toHaveBeenCalled()
return mockSetFeatures.mock.calls.at(-1)?.[0].file as FileUpload
}
describe('ParamConfigContent', () => {
beforeEach(() => {
vi.clearAllMocks()
setupFeatureStore()
})
it('should update the image resolution', async () => {
const user = userEvent.setup()
render(<ParamConfigContent />)
await user.click(screen.getByText('appDebug.vision.visionSettings.high'))
expect(getUpdatedFile().image?.detail).toBe(Resolution.high)
})
it('should update upload methods and upload limit', async () => {
const user = userEvent.setup()
render(<ParamConfigContent />)
await user.click(screen.getByText('appDebug.vision.visionSettings.localUpload'))
expect(getUpdatedFile().allowed_file_upload_methods).toEqual([TransferMethod.local_file])
fireEvent.change(screen.getByRole('textbox'), { target: { value: '5' } })
expect(getUpdatedFile().number_limits).toBe(5)
})
})

View File

@ -0,0 +1,58 @@
import type { FeatureStoreState } from '@/app/components/base/features/store'
import type { FileUpload } from '@/app/components/base/features/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Resolution, TransferMethod } from '@/types/app'
import ParamConfig from '../param-config'
const mockUseFeatures = vi.fn()
const mockUseFeaturesStore = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => {
const file: FileUpload = {
enabled: true,
allowed_file_types: [],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
image: {
enabled: true,
detail: Resolution.low,
number_limits: 3,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
},
...fileOverrides,
}
const featureStoreState = {
features: { file },
setFeatures: vi.fn(),
showFeaturesModal: false,
setShowFeaturesModal: vi.fn(),
} as unknown as FeatureStoreState
mockUseFeatures.mockImplementation(selector => selector(featureStoreState))
mockUseFeaturesStore.mockReturnValue({
getState: () => featureStoreState,
})
}
describe('ParamConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
setupFeatureStore()
})
it('should toggle the settings panel when clicking the trigger', async () => {
const user = userEvent.setup()
render(<ParamConfig />)
expect(screen.queryByText('appDebug.vision.visionSettings.title')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'appDebug.voice.settings' }))
expect(await screen.findByText('appDebug.vision.visionSettings.title')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,22 @@
import { fireEvent, render, screen } from '@testing-library/react'
import PromptToast from '../prompt-toast'
describe('PromptToast', () => {
it('should render the note title and markdown message', () => {
render(<PromptToast message="Prompt body" />)
expect(screen.getByText('appDebug.generate.optimizationNote')).toBeInTheDocument()
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
})
it('should collapse and expand the markdown content', () => {
const { container } = render(<PromptToast message="Prompt body" />)
const toggle = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(toggle)
expect(screen.queryByTestId('markdown-body')).not.toBeInTheDocument()
fireEvent.click(toggle)
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render, screen } from '@testing-library/react'
import ResPlaceholder from '../res-placeholder'
describe('ResPlaceholder', () => {
it('should render the placeholder copy', () => {
render(<ResPlaceholder />)
expect(screen.getByText('appDebug.generate.newNoDataLine1')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,39 @@
import type { GenRes } from '@/service/debug'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import useGenData from '../use-gen-data'
describe('useGenData', () => {
beforeEach(() => {
sessionStorage.clear()
})
it('should start with an empty version list', () => {
const { result } = renderHook(() => useGenData({ storageKey: 'prompt' }))
expect(result.current.versions).toEqual([])
expect(result.current.currentVersionIndex).toBe(0)
expect(result.current.current).toBeUndefined()
})
it('should append versions and keep the latest one selected', () => {
const versionOne = { modified: 'first version' } as GenRes
const versionTwo = { modified: 'second version' } as GenRes
const { result } = renderHook(() => useGenData({ storageKey: 'prompt' }))
act(() => {
result.current.addVersion(versionOne)
})
expect(result.current.versions).toEqual([versionOne])
expect(result.current.current).toEqual(versionOne)
act(() => {
result.current.addVersion(versionTwo)
})
expect(result.current.versions).toEqual([versionOne, versionTwo])
expect(result.current.currentVersionIndex).toBe(1)
expect(result.current.current).toEqual(versionTwo)
})
})

View File

@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react'
import { useDebugWithMultipleModelContext } from '../context'
import { DebugWithMultipleModelContextProvider } from '../context-provider'
const ContextConsumer = () => {
const value = useDebugWithMultipleModelContext()
return (
<div>
<div>{value.multipleModelConfigs.length}</div>
<button onClick={() => value.onMultipleModelConfigsChange(true, value.multipleModelConfigs)}>change-multiple</button>
<button onClick={() => value.onDebugWithMultipleModelChange(value.multipleModelConfigs[0])}>change-single</button>
<div>{String(value.checkCanSend?.())}</div>
</div>
)
}
describe('DebugWithMultipleModelContextProvider', () => {
it('should expose the provided context value to descendants', () => {
const onMultipleModelConfigsChange = vi.fn()
const onDebugWithMultipleModelChange = vi.fn()
const checkCanSend = vi.fn(() => true)
const multipleModelConfigs = [{ model: 'gpt-4o' }] as unknown as []
render(
<DebugWithMultipleModelContextProvider
multipleModelConfigs={multipleModelConfigs}
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
onDebugWithMultipleModelChange={onDebugWithMultipleModelChange}
checkCanSend={checkCanSend}
>
<ContextConsumer />
</DebugWithMultipleModelContextProvider>,
)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('true')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,103 @@
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { AppCardAccessControlSection, AppCardOperations, createAppCardOperations } from '../app-card-sections'
describe('app-card-sections', () => {
const t = (key: string) => key
it('should build operations with the expected disabled state', () => {
const onLaunch = vi.fn()
const operations = createAppCardOperations({
operationKeys: ['launch', 'settings'],
t: t as never,
runningStatus: false,
triggerModeDisabled: false,
onLaunch,
onEmbedded: vi.fn(),
onCustomize: vi.fn(),
onSettings: vi.fn(),
onDevelop: vi.fn(),
})
expect(operations[0]).toMatchObject({
key: 'launch',
disabled: true,
label: 'overview.appInfo.launch',
})
expect(operations[1]).toMatchObject({
key: 'settings',
disabled: false,
label: 'overview.appInfo.settings.entry',
})
})
it('should render the access-control section and call onClick', () => {
const onClick = vi.fn()
render(
<AppCardAccessControlSection
t={t as never}
appDetail={{ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as AppDetailResponse}
isAppAccessSet={false}
onClick={onClick}
/>,
)
fireEvent.click(screen.getByText('publishApp.notSet'))
expect(screen.getByText('accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should render operation buttons and execute enabled actions', () => {
const onLaunch = vi.fn()
const operations = createAppCardOperations({
operationKeys: ['launch', 'embedded'],
t: t as never,
runningStatus: true,
triggerModeDisabled: false,
onLaunch,
onEmbedded: vi.fn(),
onCustomize: vi.fn(),
onSettings: vi.fn(),
onDevelop: vi.fn(),
})
render(
<AppCardOperations
t={t as never}
operations={operations}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }))
expect(onLaunch).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument()
})
it('should keep customize available for web app cards that are not completion or workflow apps', () => {
const operations = createAppCardOperations({
operationKeys: ['customize'],
t: t as never,
runningStatus: true,
triggerModeDisabled: false,
onLaunch: vi.fn(),
onEmbedded: vi.fn(),
onCustomize: vi.fn(),
onSettings: vi.fn(),
onDevelop: vi.fn(),
})
render(
<AppCardOperations
t={t as never}
operations={operations}
/>,
)
expect(screen.getByText('overview.appInfo.customize.entry')).toBeInTheDocument()
expect(AppModeEnum.CHAT).toBe('chat')
})
})

View File

@ -0,0 +1,107 @@
import type { AppDetailResponse } from '@/models/app'
import { BlockEnum } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils'
describe('app-card-utils', () => {
const baseAppInfo = {
id: 'app-1',
mode: AppModeEnum.CHAT,
enable_site: true,
enable_api: false,
access_mode: AccessMode.PUBLIC,
api_base_url: 'https://api.example.com',
site: {
app_base_url: 'https://example.com',
access_token: 'token-1',
},
} as AppDetailResponse
it('should detect whether the workflow includes a start node', () => {
expect(hasWorkflowStartNode({
graph: {
nodes: [{ data: { type: BlockEnum.Start } }],
},
})).toBe(true)
expect(hasWorkflowStartNode({
graph: {
nodes: [{ data: { type: BlockEnum.Answer } }],
},
})).toBe(false)
})
it('should build the display state for a published web app', () => {
const state = getAppCardDisplayState({
appInfo: baseAppInfo,
cardType: 'webapp',
currentWorkflow: null,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
})
expect(state.isApp).toBe(true)
expect(state.appMode).toBe(AppModeEnum.CHAT)
expect(state.runningStatus).toBe(true)
expect(state.accessibleUrl).toBe(`https://example.com${basePath}/chat/token-1`)
})
it('should disable workflow cards without a graph or start node', () => {
const unpublishedState = getAppCardDisplayState({
appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW },
cardType: 'webapp',
currentWorkflow: null,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
})
expect(unpublishedState.appUnpublished).toBe(true)
expect(unpublishedState.toggleDisabled).toBe(true)
const missingStartState = getAppCardDisplayState({
appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW },
cardType: 'webapp',
currentWorkflow: {
graph: {
nodes: [{ data: { type: BlockEnum.Answer } }],
},
},
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
})
expect(missingStartState.missingStartNode).toBe(true)
expect(missingStartState.runningStatus).toBe(false)
})
it('should require specific access subjects only for the specific access mode', () => {
expect(isAppAccessConfigured(
{ ...baseAppInfo, access_mode: AccessMode.PUBLIC },
{ groups: [], members: [] },
)).toBe(true)
expect(isAppAccessConfigured(
{ ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS },
{ groups: [], members: [] },
)).toBe(false)
expect(isAppAccessConfigured(
{ ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS },
{ groups: [{ id: 'group-1' }], members: [] },
)).toBe(true)
})
it('should derive operation keys for api and webapp cards', () => {
expect(getAppCardOperationKeys({
cardType: 'api',
appMode: AppModeEnum.COMPLETION,
isCurrentWorkspaceEditor: true,
})).toEqual(['develop'])
expect(getAppCardOperationKeys({
cardType: 'webapp',
appMode: AppModeEnum.CHAT,
isCurrentWorkspaceEditor: false,
})).toEqual(['launch', 'embedded', 'customize'])
})
})

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { categoryKeys, tagKeys } from '../constants'
import { PluginCategoryEnum } from '../types'
describe('plugin constants', () => {
it('exposes the expected plugin tag keys', () => {
expect(tagKeys).toEqual([
'agent',
'rag',
'search',
'image',
'videos',
'weather',
'finance',
'design',
'travel',
'social',
'news',
'medical',
'productivity',
'education',
'business',
'entertainment',
'utilities',
'other',
])
})
it('exposes the expected category keys in display order', () => {
expect(categoryKeys).toEqual([
PluginCategoryEnum.model,
PluginCategoryEnum.tool,
PluginCategoryEnum.datasource,
PluginCategoryEnum.agent,
PluginCategoryEnum.extension,
'bundle',
PluginCategoryEnum.trigger,
])
})
})

View File

@ -0,0 +1,104 @@
import type { Plugin } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { ThemeProvider } from 'next-themes'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ProviderCard from '../provider-card'
import { PluginCategoryEnum } from '../types'
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: Record<string, string>) => value['en-US'] || value.en_US,
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="install-modal">
<button data-testid="close-install-modal" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('@/app/components/plugins/marketplace/utils', () => ({
getPluginLinkInMarketplace: (plugin: Plugin, params: Record<string, string>) =>
`/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`,
}))
vi.mock('../card/base/card-icon', () => ({
default: ({ src }: { src: string }) => <div data-testid="provider-icon">{src}</div>,
}))
vi.mock('../card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('../card/base/download-count', () => ({
default: ({ downloadCount }: { downloadCount: number }) => <div data-testid="download-count">{downloadCount}</div>,
}))
vi.mock('../card/base/title', () => ({
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
}))
const payload = {
type: 'plugin',
org: 'dify',
name: 'provider-one',
plugin_id: 'provider-one',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg-1',
icon: 'icon.png',
verified: true,
label: { 'en-US': 'Provider One' },
brief: { 'en-US': 'Provider description' },
description: { 'en-US': 'Full description' },
introduction: 'Intro',
repository: 'https://github.com/dify/provider-one',
category: PluginCategoryEnum.tool,
install_count: 123,
endpoint: { settings: [] },
tags: [{ name: 'search' }, { name: 'rag' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
} as Plugin
describe('ProviderCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const renderProviderCard = () => render(
<ThemeProvider forcedTheme="light">
<ProviderCard payload={payload} />
</ThemeProvider>,
)
it('renders provider information, tags, and detail link', () => {
renderProviderCard()
expect(screen.getByTestId('title')).toHaveTextContent('Provider One')
expect(screen.getByText('dify')).toBeInTheDocument()
expect(screen.getByTestId('download-count')).toHaveTextContent('123')
expect(screen.getByTestId('description')).toHaveTextContent('Provider description')
expect(screen.getByText('search')).toBeInTheDocument()
expect(screen.getByText('rag')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /plugin.detailPanel.operation.detail/i })).toHaveAttribute(
'href',
'/marketplace/dify/provider-one?language=en-US&theme=system',
)
})
it('opens and closes the install modal', () => {
renderProviderCard()
fireEvent.click(screen.getByRole('button', { name: /plugin.detailPanel.operation.install/i }))
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-install-modal'))
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,22 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import useGetIcon from '../use-get-icon'
vi.mock('@/config', () => ({
API_PREFIX: 'https://api.example.com',
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { currentWorkspace: { id: string } }) => string | { id: string }) =>
selector({ currentWorkspace: { id: 'workspace-123' } }),
}))
describe('useGetIcon', () => {
it('builds icon url with current workspace id', () => {
const { result } = renderHook(() => useGetIcon())
expect(result.current.getIconUrl('plugin-icon.png')).toBe(
'https://api.example.com/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=plugin-icon.png',
)
})
})

View File

@ -0,0 +1,136 @@
import type { GitHubItemAndMarketPlaceDependency, Plugin } from '../../../../types'
import type { VersionProps } from '@/app/components/plugins/types'
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import GithubItem from '../github-item'
const mockUseUploadGitHub = vi.fn()
const mockPluginManifestToCardPluginProps = vi.fn()
const mockLoadedItem = vi.fn()
vi.mock('@/service/use-plugins', () => ({
useUploadGitHub: (params: { repo: string, version: string, package: string }) => mockUseUploadGitHub(params),
}))
vi.mock('../../../utils', () => ({
pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest),
}))
vi.mock('../../../base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('../loaded-item', () => ({
default: (props: Record<string, unknown>) => {
mockLoadedItem(props)
return <div data-testid="loaded-item">loaded-item</div>
},
}))
const dependency: GitHubItemAndMarketPlaceDependency = {
type: 'github',
value: {
repo: 'dify/plugin',
release: 'v1.0.0',
package: 'plugin.zip',
},
}
const versionInfo: VersionProps = {
hasInstalled: false,
installedVersion: '',
toInstallVersion: '1.0.0',
}
describe('GithubItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders loading state before payload is ready', () => {
mockUseUploadGitHub.mockReturnValue({ data: null, error: null })
render(
<GithubItem
checked={false}
onCheckedChange={vi.fn()}
dependency={dependency}
versionInfo={versionInfo}
onFetchedPayload={vi.fn()}
onFetchError={vi.fn()}
/>,
)
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(mockUseUploadGitHub).toHaveBeenCalledWith({
repo: 'dify/plugin',
version: 'v1.0.0',
package: 'plugin.zip',
})
})
it('converts fetched manifest and renders LoadedItem', async () => {
const onFetchedPayload = vi.fn()
const payload = {
plugin_id: 'plugin-1',
name: 'Plugin One',
org: 'dify',
icon: 'icon.png',
version: '1.0.0',
} as Plugin
mockUseUploadGitHub.mockReturnValue({
data: {
manifest: { name: 'manifest' },
unique_identifier: 'plugin-1',
},
error: null,
})
mockPluginManifestToCardPluginProps.mockReturnValue(payload)
render(
<GithubItem
checked
onCheckedChange={vi.fn()}
dependency={dependency}
versionInfo={versionInfo}
onFetchedPayload={onFetchedPayload}
onFetchError={vi.fn()}
/>,
)
await waitFor(() => {
expect(onFetchedPayload).toHaveBeenCalledWith(payload)
expect(screen.getByTestId('loaded-item')).toBeInTheDocument()
})
expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({
checked: true,
versionInfo,
payload: expect.objectContaining({
...payload,
from: 'github',
}),
}))
})
it('reports fetch error from upload hook', async () => {
const onFetchError = vi.fn()
mockUseUploadGitHub.mockReturnValue({ data: null, error: new Error('boom') })
render(
<GithubItem
checked={false}
onCheckedChange={vi.fn()}
dependency={dependency}
versionInfo={versionInfo}
onFetchedPayload={vi.fn()}
onFetchError={onFetchError}
/>,
)
await waitFor(() => {
expect(onFetchError).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,160 @@
import type { Plugin } from '../../../../types'
import type { VersionProps } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LoadedItem from '../loaded-item'
const mockCheckbox = vi.fn()
const mockCard = vi.fn()
const mockVersion = vi.fn()
const mockUsePluginInstallLimit = vi.fn()
vi.mock('@/config', () => ({
API_PREFIX: 'https://api.example.com',
MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: (props: { checked: boolean, disabled: boolean, onCheck: () => void }) => {
mockCheckbox(props)
return (
<button
data-testid="checkbox"
disabled={props.disabled}
onClick={props.onCheck}
>
{String(props.checked)}
</button>
)
},
}))
vi.mock('../../../../card', () => ({
default: (props: { titleLeft?: React.ReactNode }) => {
mockCard(props)
return (
<div data-testid="card">
{props.titleLeft}
</div>
)
},
}))
vi.mock('../../../base/use-get-icon', () => ({
default: () => ({
getIconUrl: (icon: string) => `https://api.example.com/${icon}`,
}),
}))
vi.mock('../../../base/version', () => ({
default: (props: Record<string, unknown>) => {
mockVersion(props)
return <div data-testid="version">version</div>
},
}))
vi.mock('../../../hooks/use-install-plugin-limit', () => ({
default: (payload: Plugin) => mockUsePluginInstallLimit(payload),
}))
const payload = {
plugin_id: 'plugin-1',
org: 'dify',
name: 'Loaded Plugin',
icon: 'icon.png',
version: '1.0.0',
} as Plugin
const versionInfo: VersionProps = {
hasInstalled: false,
installedVersion: '',
toInstallVersion: '0.9.0',
}
describe('LoadedItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUsePluginInstallLimit.mockReturnValue({ canInstall: true })
})
it('uses local icon url and forwards version title for non-marketplace plugins', () => {
render(
<LoadedItem
checked
onCheckedChange={vi.fn()}
payload={payload}
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('card')).toBeInTheDocument()
expect(mockUsePluginInstallLimit).toHaveBeenCalledWith(payload)
expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({
limitedInstall: false,
payload: expect.objectContaining({
...payload,
icon: 'https://api.example.com/icon.png',
}),
titleLeft: expect.anything(),
}))
expect(mockVersion).toHaveBeenCalledWith(expect.objectContaining({
hasInstalled: false,
installedVersion: '',
toInstallVersion: '1.0.0',
}))
})
it('uses marketplace icon url and disables checkbox when install limit is reached', () => {
mockUsePluginInstallLimit.mockReturnValue({ canInstall: false })
render(
<LoadedItem
checked={false}
onCheckedChange={vi.fn()}
payload={payload}
isFromMarketPlace
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('checkbox')).toBeDisabled()
expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({
limitedInstall: true,
payload: expect.objectContaining({
icon: 'https://marketplace.example.com/plugins/dify/Loaded Plugin/icon',
}),
}))
})
it('calls onCheckedChange with payload when checkbox is toggled', () => {
const onCheckedChange = vi.fn()
render(
<LoadedItem
checked={false}
onCheckedChange={onCheckedChange}
payload={payload}
versionInfo={versionInfo}
/>,
)
fireEvent.click(screen.getByTestId('checkbox'))
expect(onCheckedChange).toHaveBeenCalledWith(payload)
})
it('omits version badge when payload has no version', () => {
render(
<LoadedItem
checked={false}
onCheckedChange={vi.fn()}
payload={{ ...payload, version: '' }}
versionInfo={versionInfo}
/>,
)
expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({
titleLeft: null,
}))
})
})

View File

@ -0,0 +1,69 @@
import type { Plugin } from '../../../../types'
import type { VersionProps } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MarketPlaceItem from '../marketplace-item'
const mockLoadedItem = vi.fn()
vi.mock('../../../base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('../loaded-item', () => ({
default: (props: Record<string, unknown>) => {
mockLoadedItem(props)
return <div data-testid="loaded-item">loaded-item</div>
},
}))
const payload = {
plugin_id: 'plugin-1',
org: 'dify',
name: 'Marketplace Plugin',
icon: 'icon.png',
} as Plugin
const versionInfo: VersionProps = {
hasInstalled: false,
installedVersion: '',
toInstallVersion: '1.0.0',
}
describe('MarketPlaceItem', () => {
it('renders loading when payload is absent', () => {
render(
<MarketPlaceItem
checked={false}
onCheckedChange={vi.fn()}
version="1.0.0"
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('renders LoadedItem with marketplace payload and version', () => {
render(
<MarketPlaceItem
checked
onCheckedChange={vi.fn()}
payload={payload}
version="2.0.0"
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('loaded-item')).toBeInTheDocument()
expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({
checked: true,
isFromMarketPlace: true,
versionInfo,
payload: expect.objectContaining({
...payload,
version: '2.0.0',
}),
}))
})
})

View File

@ -0,0 +1,124 @@
import type { PackageDependency } from '../../../../types'
import type { VersionProps } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../../types'
import PackageItem from '../package-item'
const mockPluginManifestToCardPluginProps = vi.fn()
const mockLoadedItem = vi.fn()
vi.mock('../../../utils', () => ({
pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest),
}))
vi.mock('../../../base/loading-error', () => ({
default: () => <div data-testid="loading-error">loading-error</div>,
}))
vi.mock('../loaded-item', () => ({
default: (props: Record<string, unknown>) => {
mockLoadedItem(props)
return <div data-testid="loaded-item">loaded-item</div>
},
}))
const versionInfo: VersionProps = {
hasInstalled: false,
installedVersion: '',
toInstallVersion: '1.0.0',
}
const payload = {
type: 'package',
value: {
manifest: {
plugin_unique_identifier: 'plugin-1',
version: '1.0.0',
author: 'dify',
icon: 'icon.png',
name: 'Package Plugin',
category: PluginCategoryEnum.tool,
label: { en_US: 'Package Plugin', zh_Hans: 'Package Plugin' },
description: { en_US: 'Description', zh_Hans: 'Description' },
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {
events: [],
identity: {
author: 'dify',
name: 'trigger',
description: { en_US: 'Trigger', zh_Hans: 'Trigger' },
icon: 'icon.png',
label: { en_US: 'Trigger', zh_Hans: 'Trigger' },
tags: [],
},
subscription_constructor: {
credentials_schema: [],
oauth_schema: {
client_schema: [],
credentials_schema: [],
},
parameters: [],
},
subscription_schema: [],
},
},
},
} as unknown as PackageDependency
describe('PackageItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders loading error when manifest is missing', () => {
render(
<PackageItem
checked={false}
onCheckedChange={vi.fn()}
payload={{ type: 'package', value: {} } as unknown as PackageDependency}
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('loading-error')).toBeInTheDocument()
})
it('renders LoadedItem with converted plugin payload', () => {
mockPluginManifestToCardPluginProps.mockReturnValue({
plugin_id: 'plugin-1',
name: 'Package Plugin',
org: 'dify',
icon: 'icon.png',
})
render(
<PackageItem
checked
onCheckedChange={vi.fn()}
payload={payload}
versionInfo={versionInfo}
isFromMarketPlace
/>,
)
expect(screen.getByTestId('loaded-item')).toBeInTheDocument()
expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({
checked: true,
isFromMarketPlace: true,
versionInfo,
payload: expect.objectContaining({
plugin_id: 'plugin-1',
from: 'package',
}),
}))
})
})

View File

@ -0,0 +1,114 @@
import type { InstallStatus, Plugin } from '../../../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Installed from '../installed'
const mockCard = vi.fn()
vi.mock('@/config', () => ({
API_PREFIX: 'https://api.example.com',
MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
}))
vi.mock('@/app/components/plugins/card', () => ({
default: (props: { titleLeft?: React.ReactNode }) => {
mockCard(props)
return (
<div data-testid="card">
{props.titleLeft}
</div>
)
},
}))
vi.mock('../../../base/use-get-icon', () => ({
default: () => ({
getIconUrl: (icon: string) => `https://api.example.com/${icon}`,
}),
}))
const plugins = [
{
plugin_id: 'plugin-1',
org: 'dify',
name: 'Plugin One',
icon: 'icon-1.png',
version: '1.0.0',
},
{
plugin_id: 'plugin-2',
org: 'dify',
name: 'Plugin Two',
icon: 'icon-2.png',
version: '2.0.0',
},
] as Plugin[]
const installStatus: InstallStatus[] = [
{ success: true, isFromMarketPlace: true },
{ success: false, isFromMarketPlace: false },
]
describe('Installed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders plugin cards with install status and marketplace icon handling', () => {
render(
<Installed
list={plugins}
installStatus={installStatus}
onCancel={vi.fn()}
/>,
)
expect(screen.getAllByTestId('card')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
expect(screen.getByText('1.0.0')).toBeInTheDocument()
expect(screen.getByText('2.0.0')).toBeInTheDocument()
expect(mockCard).toHaveBeenNthCalledWith(1, expect.objectContaining({
installed: true,
installFailed: false,
payload: expect.objectContaining({
icon: 'https://marketplace.example.com/plugins/dify/Plugin One/icon',
}),
}))
expect(mockCard).toHaveBeenNthCalledWith(2, expect.objectContaining({
installed: false,
installFailed: true,
payload: expect.objectContaining({
icon: 'https://api.example.com/icon-2.png',
}),
}))
})
it('calls onCancel when close button is clicked', () => {
const onCancel = vi.fn()
render(
<Installed
list={plugins}
installStatus={installStatus}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('hides action button when isHideButton is true', () => {
render(
<Installed
list={plugins}
installStatus={installStatus}
onCancel={vi.fn()}
isHideButton
/>,
)
expect(screen.queryByRole('button', { name: 'common.operation.close' })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { PluginCategoryEnum } from '../../types'
import {
DEFAULT_SORT,
PLUGIN_CATEGORY_WITH_COLLECTIONS,
PLUGIN_TYPE_SEARCH_MAP,
SCROLL_BOTTOM_THRESHOLD,
} from '../constants'
describe('marketplace constants', () => {
it('defines the expected default sort', () => {
expect(DEFAULT_SORT).toEqual({
sortBy: 'install_count',
sortOrder: 'DESC',
})
})
it('defines the expected plugin search type map', () => {
expect(PLUGIN_TYPE_SEARCH_MAP).toEqual({
all: 'all',
model: PluginCategoryEnum.model,
tool: PluginCategoryEnum.tool,
agent: PluginCategoryEnum.agent,
extension: PluginCategoryEnum.extension,
datasource: PluginCategoryEnum.datasource,
trigger: PluginCategoryEnum.trigger,
bundle: 'bundle',
})
expect(SCROLL_BOTTOM_THRESHOLD).toBe(100)
})
it('tracks only collection-backed categories', () => {
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.all)).toBe(true)
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(true)
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.model)).toBe(false)
})
})

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import { marketplaceSearchParamsParsers } from '../search-params'
describe('marketplace search params', () => {
it('applies the expected default values', () => {
expect(marketplaceSearchParamsParsers.category.parseServerSide(undefined)).toBe(PLUGIN_TYPE_SEARCH_MAP.all)
expect(marketplaceSearchParamsParsers.q.parseServerSide(undefined)).toBe('')
expect(marketplaceSearchParamsParsers.tags.parseServerSide(undefined)).toEqual([])
})
it('parses supported query values with the configured parsers', () => {
expect(marketplaceSearchParamsParsers.category.parseServerSide(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(PLUGIN_TYPE_SEARCH_MAP.tool)
expect(marketplaceSearchParamsParsers.category.parseServerSide('unsupported')).toBe(PLUGIN_TYPE_SEARCH_MAP.all)
expect(marketplaceSearchParamsParsers.q.parseServerSide('keyword')).toBe('keyword')
expect(marketplaceSearchParamsParsers.tags.parseServerSide('rag,search')).toEqual(['rag', 'search'])
})
})

View File

@ -0,0 +1,30 @@
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Line from '../line'
const mockUseTheme = vi.fn()
vi.mock('@/hooks/use-theme', () => ({
default: () => mockUseTheme(),
}))
describe('Line', () => {
it('renders dark mode svg variant', () => {
mockUseTheme.mockReturnValue({ theme: 'dark' })
const { container } = render(<Line className="divider" />)
const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('height', '240')
expect(svg).toHaveAttribute('viewBox', '0 0 2 240')
expect(svg).toHaveClass('divider')
})
it('renders light mode svg variant', () => {
mockUseTheme.mockReturnValue({ theme: 'light' })
const { container } = render(<Line />)
const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('height', '241')
expect(svg).toHaveAttribute('viewBox', '0 0 2 241')
})
})

View File

@ -0,0 +1,115 @@
import type { ComponentProps } from 'react'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { ThemeProvider } from 'next-themes'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import CardWrapper from '../card-wrapper'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
useLocale: () => 'en-US',
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
getTagLabel: (name: string) => `tag:${name}`,
}),
}))
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => (
<div data-testid="card">
<span>{payload.name}</span>
{footer}
</div>
),
}))
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => (
<div data-testid="card-more-info">
{downloadCount}
:
{tags.join('|')}
</div>
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="install-modal">
<button data-testid="close-install-modal" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('../../utils', () => ({
getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/detail/${plugin.org}/${plugin.name}`,
getPluginLinkInMarketplace: (plugin: Plugin, params: Record<string, string>) => `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`,
}))
const plugin = {
type: 'plugin',
org: 'dify',
name: 'plugin-a',
plugin_id: 'plugin-a',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg',
icon: 'icon.png',
verified: true,
label: { 'en-US': 'Plugin A' },
brief: { 'en-US': 'Brief' },
description: { 'en-US': 'Description' },
introduction: 'Intro',
repository: 'https://github.com/dify/plugin-a',
category: PluginCategoryEnum.tool,
install_count: 42,
endpoint: { settings: [] },
tags: [{ name: 'search' }, { name: 'agent' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
} as Plugin
describe('CardWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const renderCardWrapper = (props: Partial<ComponentProps<typeof CardWrapper>> = {}) => render(
<ThemeProvider forcedTheme="dark">
<CardWrapper plugin={plugin} {...props} />
</ThemeProvider>,
)
it('renders plugin detail link when install button is hidden', () => {
renderCardWrapper()
expect(screen.getByRole('link')).toHaveAttribute('href', '/detail/dify/plugin-a')
expect(screen.getByTestId('card-more-info')).toHaveTextContent('42:tag:search|tag:agent')
})
it('renders install and marketplace detail actions when install button is shown', () => {
renderCardWrapper({ showInstallButton: true })
expect(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' })).toHaveAttribute(
'href',
'/marketplace/dify/plugin-a?language=en-US&theme=system',
)
})
it('opens and closes install modal from install action', () => {
renderCardWrapper({ showInstallButton: true })
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' }))
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-install-modal'))
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,102 @@
import type { MarketplaceCollection } from '../../types'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ListWithCollection from '../list-with-collection'
const mockMoreClick = vi.fn()
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
useLocale: () => 'en-US',
}))
vi.mock('../../atoms', () => ({
useMarketplaceMoreClick: () => mockMoreClick,
}))
vi.mock('@/i18n-config/language', () => ({
getLanguage: (locale: string) => locale,
}))
vi.mock('../card-wrapper', () => ({
default: ({ plugin }: { plugin: Plugin }) => <div data-testid="card-wrapper">{plugin.name}</div>,
}))
const collections: MarketplaceCollection[] = [
{
name: 'featured',
label: { 'en-US': 'Featured' },
description: { 'en-US': 'Featured plugins' },
rule: 'featured',
created_at: '',
updated_at: '',
searchable: true,
search_params: { query: 'featured' },
},
{
name: 'empty',
label: { 'en-US': 'Empty' },
description: { 'en-US': 'No plugins' },
rule: 'empty',
created_at: '',
updated_at: '',
searchable: false,
search_params: {},
},
]
const pluginsMap: Record<string, Plugin[]> = {
featured: [
{ plugin_id: 'p1', name: 'Plugin One' },
{ plugin_id: 'p2', name: 'Plugin Two' },
] as Plugin[],
empty: [],
}
describe('ListWithCollection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders only collections that contain plugins', () => {
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
expect(screen.getByText('Featured')).toBeInTheDocument()
expect(screen.queryByText('Empty')).not.toBeInTheDocument()
expect(screen.getAllByTestId('card-wrapper')).toHaveLength(2)
})
it('calls more handler for searchable collection', () => {
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
fireEvent.click(screen.getByText('plugin.marketplace.viewMore'))
expect(mockMoreClick).toHaveBeenCalledWith({ query: 'featured' })
})
it('uses custom card renderer when provided', () => {
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
cardRender={plugin => <div key={plugin.plugin_id} data-testid="custom-card">{plugin.name}</div>}
/>,
)
expect(screen.getAllByTestId('custom-card')).toHaveLength(2)
expect(screen.queryByTestId('card-wrapper')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,92 @@
import type { MarketplaceCollection } from '../../types'
import type { Plugin } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ListWrapper from '../list-wrapper'
const mockMarketplaceData = vi.hoisted(() => ({
plugins: undefined as Plugin[] | undefined,
pluginsTotal: 0,
marketplaceCollections: [] as MarketplaceCollection[],
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
isLoading: false,
isFetchingNextPage: false,
page: 1,
}))
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, num?: number }) =>
key === 'marketplace.pluginsResult' && options?.ns === 'plugin'
? `${options.num} plugins found`
: options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('../../state', () => ({
useMarketplaceData: () => mockMarketplaceData,
}))
vi.mock('@/app/components/base/loading', () => ({
default: ({ className }: { className?: string }) => <div data-testid="loading" className={className}>loading</div>,
}))
vi.mock('../../sort-dropdown', () => ({
default: () => <div data-testid="sort-dropdown">sort</div>,
}))
vi.mock('../index', () => ({
default: ({ plugins }: { plugins?: Plugin[] }) => <div data-testid="list">{plugins?.length ?? 'collections'}</div>,
}))
describe('ListWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMarketplaceData.plugins = undefined
mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.marketplaceCollections = []
mockMarketplaceData.marketplaceCollectionPluginsMap = {}
mockMarketplaceData.isLoading = false
mockMarketplaceData.isFetchingNextPage = false
mockMarketplaceData.page = 1
})
it('shows result header and sort dropdown when plugins are loaded', () => {
mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
mockMarketplaceData.pluginsTotal = 1
render(<ListWrapper />)
expect(screen.getByText('1 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
})
it('shows centered loading only on initial loading page', () => {
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 1
render(<ListWrapper />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(screen.queryByTestId('list')).not.toBeInTheDocument()
})
it('renders list when loading additional pages', () => {
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2
mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
render(<ListWrapper showInstallButton />)
expect(screen.getByTestId('list')).toBeInTheDocument()
})
it('shows bottom loading indicator while fetching next page', () => {
mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
mockMarketplaceData.isFetchingNextPage = true
render(<ListWrapper />)
expect(screen.getAllByTestId('loading')).toHaveLength(1)
})
})

View File

@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import SearchBoxWrapper from '../search-box-wrapper'
const mockHandleSearchPluginTextChange = vi.fn()
const mockHandleFilterPluginTagsChange = vi.fn()
const mockSearchBox = vi.fn()
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('../../atoms', () => ({
useSearchPluginText: () => ['plugin search', mockHandleSearchPluginTextChange],
useFilterPluginTags: () => [['agent', 'rag'], mockHandleFilterPluginTagsChange],
}))
vi.mock('../index', () => ({
default: (props: Record<string, unknown>) => {
mockSearchBox(props)
return <div data-testid="search-box">search-box</div>
},
}))
describe('SearchBoxWrapper', () => {
it('passes marketplace search state into SearchBox', () => {
render(<SearchBoxWrapper />)
expect(screen.getByTestId('search-box')).toBeInTheDocument()
expect(mockSearchBox).toHaveBeenCalledWith(expect.objectContaining({
wrapperClassName: 'z-11 mx-auto w-[640px] shrink-0',
inputClassName: 'w-full',
search: 'plugin search',
onSearchChange: mockHandleSearchPluginTextChange,
tags: ['agent', 'rag'],
onTagsChange: mockHandleFilterPluginTagsChange,
placeholder: 'plugin.searchPlugins',
usedInMarketplace: true,
}))
})
})

View File

@ -0,0 +1,126 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagsFilter from '../tags-filter'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: [
{ name: 'agent', label: 'Agent' },
{ name: 'rag', label: 'RAG' },
{ name: 'search', label: 'Search' },
],
tagsMap: {
agent: { name: 'agent', label: 'Agent' },
rag: { name: 'rag', label: 'RAG' },
search: { name: 'search', label: 'Search' },
},
}),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked }: { checked: boolean }) => <span data-testid="checkbox">{String(checked)}</span>,
}))
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
placeholder,
}: {
value: string
onChange: (event: { target: { value: string } }) => void
placeholder: string
}) => (
<input
aria-label="tags-search"
value={value}
placeholder={placeholder}
onChange={event => onChange({ target: { value: event.target.value } })}
/>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const React = await import('react')
return {
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="portal-trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
}
})
vi.mock('../trigger/marketplace', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
<div data-testid="marketplace-trigger">
marketplace:
{selectedTagsLength}
</div>
),
}))
vi.mock('../trigger/tool-selector', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
<div data-testid="tool-trigger">
tool:
{selectedTagsLength}
</div>
),
}))
describe('TagsFilter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders marketplace trigger when used in marketplace', () => {
render(<TagsFilter tags={['agent']} onTagsChange={vi.fn()} usedInMarketplace />)
expect(screen.getByTestId('marketplace-trigger')).toHaveTextContent('marketplace:1')
expect(screen.queryByTestId('tool-trigger')).not.toBeInTheDocument()
})
it('renders tool selector trigger when used outside marketplace', () => {
render(<TagsFilter tags={['agent']} onTagsChange={vi.fn()} />)
expect(screen.getByTestId('tool-trigger')).toHaveTextContent('tool:1')
expect(screen.queryByTestId('marketplace-trigger')).not.toBeInTheDocument()
})
it('filters tag options by search text', () => {
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
expect(screen.getByText('Agent')).toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
expect(screen.getByText('Search')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('tags-search'), { target: { value: 'ra' } })
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
expect(screen.queryByText('Search')).not.toBeInTheDocument()
})
it('adds and removes selected tags when options are clicked', () => {
const onTagsChange = vi.fn()
const { rerender } = render(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(screen.getByText('Agent'))
expect(onTagsChange).toHaveBeenCalledWith([])
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(screen.getByText('RAG'))
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
})
})

View File

@ -0,0 +1,67 @@
import type { Tag } from '../../../../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MarketplaceTrigger from '../marketplace'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
const tagsMap: Record<string, Tag> = {
agent: { name: 'agent', label: 'Agent' },
rag: { name: 'rag', label: 'RAG' },
search: { name: 'search', label: 'Search' },
}
describe('MarketplaceTrigger', () => {
it('shows all-tags text when no tags are selected', () => {
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
expect(container.querySelectorAll('svg').length).toBe(2)
})
it('shows selected tag labels and overflow count', () => {
render(
<MarketplaceTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('clears selected tags when clear icon is clicked', () => {
const onTagsChange = vi.fn()
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={1}
open={false}
tags={['agent']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>,
)
fireEvent.click(container.querySelectorAll('svg')[1]!)
expect(onTagsChange).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,61 @@
import type { Tag } from '../../../../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ToolSelectorTrigger from '../tool-selector'
const tagsMap: Record<string, Tag> = {
agent: { name: 'agent', label: 'Agent' },
rag: { name: 'rag', label: 'RAG' },
search: { name: 'search', label: 'Search' },
}
describe('ToolSelectorTrigger', () => {
it('renders only icon when no tags are selected', () => {
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(container.querySelectorAll('svg')).toHaveLength(1)
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
})
it('renders selected tag labels and overflow count', () => {
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
expect(container.querySelectorAll('svg')).toHaveLength(2)
})
it('clears selected tags when clear icon is clicked', () => {
const onTagsChange = vi.fn()
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={1}
open={false}
tags={['agent']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>,
)
fireEvent.click(container.querySelectorAll('svg')[1]!)
expect(onTagsChange).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,106 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import AppInputsForm from '../app-inputs-form'
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
}: {
onChange: (files: Array<Record<string, unknown>>) => void
}) => (
<button data-testid="file-uploader" onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}>
Upload
</button>
),
}))
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({
items,
onSelect,
}: {
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
}) => (
<div>
{items.map(item => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
{item.name}
</button>
))}
</div>
),
}))
describe('AppInputsForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should update text input values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { question: '' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'question', label: 'Question', type: InputVarType.textInput, required: false }]}
inputs={{ question: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('Question'), {
target: { value: 'hello' },
})
expect(onFormChange).toHaveBeenCalledWith({ question: 'hello' })
})
it('should update select values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { tone: '' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'tone', label: 'Tone', type: InputVarType.select, options: ['friendly', 'formal'], required: false }]}
inputs={{ tone: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.click(screen.getByTestId('select-formal'))
expect(onFormChange).toHaveBeenCalledWith({ tone: 'formal' })
})
it('should update uploaded single file values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { attachment: null } }
render(
<AppInputsForm
inputsForms={[{
variable: 'attachment',
label: 'Attachment',
type: InputVarType.singleFile,
required: false,
allowed_file_types: [],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
}]}
inputs={{ attachment: null }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.click(screen.getByTestId('file-uploader'))
expect(onFormChange).toHaveBeenCalledWith({
attachment: { id: 'file-1', name: 'demo.png' },
})
})
})

View File

@ -0,0 +1,87 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppInputsPanel from '../app-inputs-panel'
let mockHookResult = {
inputFormSchema: [] as Array<Record<string, unknown>>,
isLoading: false,
}
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">Loading</div>,
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form', () => ({
default: ({
onFormChange,
}: {
onFormChange: (value: Record<string, unknown>) => void
}) => (
<button data-testid="app-inputs-form" onClick={() => onFormChange({ topic: 'updated' })}>
Form
</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema', () => ({
useAppInputsFormSchema: () => mockHookResult,
}))
describe('AppInputsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHookResult = {
inputFormSchema: [],
isLoading: false,
}
})
it('should render a loading state', () => {
mockHookResult = {
inputFormSchema: [],
isLoading: true,
}
render(
<AppInputsPanel
value={{ app_id: 'app-1', inputs: {} }}
appDetail={{ id: 'app-1' } as never}
onFormChange={vi.fn()}
/>,
)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('should render an empty state when no inputs are available', () => {
render(
<AppInputsPanel
value={{ app_id: 'app-1', inputs: {} }}
appDetail={{ id: 'app-1' } as never}
onFormChange={vi.fn()}
/>,
)
expect(screen.getByText('app.appSelector.noParams')).toBeInTheDocument()
})
it('should render the inputs form and propagate changes', () => {
const onFormChange = vi.fn()
mockHookResult = {
inputFormSchema: [{ variable: 'topic' }],
isLoading: false,
}
render(
<AppInputsPanel
value={{ app_id: 'app-1', inputs: { topic: 'initial' } }}
appDetail={{ id: 'app-1' } as never}
onFormChange={onFormChange}
/>,
)
fireEvent.click(screen.getByTestId('app-inputs-form'))
expect(onFormChange).toHaveBeenCalledWith({ topic: 'updated' })
})
})

View File

@ -0,0 +1,179 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import AppPicker from '../app-picker'
class MockIntersectionObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
class MockMutationObserver {
observe = vi.fn()
disconnect = vi.fn()
takeRecords = vi.fn().mockReturnValue([])
}
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
vi.stubGlobal('MutationObserver', MockMutationObserver)
})
vi.mock('@/app/components/base/app-icon', () => ({
default: () => <div data-testid="app-icon" />,
}))
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
onClear,
}: {
value: string
onChange: (e: { target: { value: string } }) => void
onClear?: () => void
}) => (
<div>
<input
data-testid="search-input"
value={value}
onChange={e => onChange({ target: { value: e.target.value } })}
/>
<button data-testid="clear-input" onClick={onClear}>Clear</button>
</div>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
children: ReactNode
open: boolean
}) => (
<div data-testid="portal" data-open={open}>
{children}
</div>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: ReactNode
onClick?: () => void
}) => (
<button data-testid="picker-trigger" onClick={onClick}>
{children}
</button>
),
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
const apps = [
{
id: 'app-1',
name: 'Chat App',
mode: AppModeEnum.CHAT,
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
},
{
id: 'app-2',
name: 'Workflow App',
mode: AppModeEnum.WORKFLOW,
icon_type: 'emoji',
icon: '⚙️',
icon_background: '#fff',
},
]
describe('AppPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open when the trigger is clicked', () => {
const onShowChange = vi.fn()
render(
<AppPicker
scope="all"
disabled={false}
trigger={<span>Trigger</span>}
isShow={false}
onShowChange={onShowChange}
onSelect={vi.fn()}
apps={apps as never}
isLoading={false}
hasMore={false}
onLoadMore={vi.fn()}
searchText=""
onSearchChange={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('picker-trigger'))
expect(onShowChange).toHaveBeenCalledWith(true)
})
it('should render apps, select one, and handle search changes', () => {
const onSelect = vi.fn()
const onSearchChange = vi.fn()
render(
<AppPicker
scope="all"
disabled={false}
trigger={<span>Trigger</span>}
isShow
onShowChange={vi.fn()}
onSelect={onSelect}
apps={apps as never}
isLoading={false}
hasMore={false}
onLoadMore={vi.fn()}
searchText="chat"
onSearchChange={onSearchChange}
/>,
)
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'workflow' },
})
fireEvent.click(screen.getByText('Workflow App'))
fireEvent.click(screen.getByTestId('clear-input'))
expect(onSearchChange).toHaveBeenCalledWith('workflow')
expect(onSearchChange).toHaveBeenCalledWith('')
expect(onSelect).toHaveBeenCalledWith(apps[1])
expect(screen.getByText('chat')).toBeInTheDocument()
})
it('should render loading text when loading more apps', () => {
render(
<AppPicker
scope="all"
disabled={false}
trigger={<span>Trigger</span>}
isShow
onShowChange={vi.fn()}
onSelect={vi.fn()}
apps={apps as never}
isLoading
hasMore
onLoadMore={vi.fn()}
searchText=""
onSearchChange={vi.fn()}
/>,
)
expect(screen.getByText('common.loading')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,141 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { AppModeEnum, Resolution } from '@/types/app'
import { useAppInputsFormSchema } from '../use-app-inputs-form-schema'
let mockAppDetailData: Record<string, unknown> | null = null
let mockAppWorkflowData: Record<string, unknown> | null = null
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: {
file_size_limit: 15,
image_file_size_limit: 10,
},
}),
}))
vi.mock('@/service/use-apps', () => ({
useAppDetail: () => ({
data: mockAppDetailData,
isFetching: false,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: mockAppWorkflowData,
isFetching: false,
}),
}))
describe('useAppInputsFormSchema', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetailData = null
mockAppWorkflowData = null
})
it('should build basic app schemas and append image upload support', () => {
mockAppDetailData = {
id: 'app-1',
mode: AppModeEnum.COMPLETION,
model_config: {
user_input_form: [
{
'text-input': {
label: 'Question',
variable: 'question',
},
},
],
file_upload: {
enabled: true,
image: {
enabled: true,
detail: Resolution.high,
number_limits: 2,
transfer_methods: ['local_file'],
},
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
number_limits: 2,
},
},
}
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: {
id: 'app-1',
mode: AppModeEnum.COMPLETION,
} as never,
}))
expect(result.current.isLoading).toBe(false)
expect(result.current.inputFormSchema).toEqual(expect.arrayContaining([
expect.objectContaining({
variable: 'question',
type: 'text-input',
}),
expect.objectContaining({
variable: '#image#',
type: InputVarType.singleFile,
allowed_file_extensions: ['.png'],
}),
]))
})
it('should build workflow schemas from start node variables', () => {
mockAppDetailData = {
id: 'app-2',
mode: AppModeEnum.WORKFLOW,
}
mockAppWorkflowData = {
graph: {
nodes: [
{
data: {
type: BlockEnum.Start,
variables: [
{
label: 'Attachments',
variable: 'attachments',
type: InputVarType.multiFiles,
},
],
},
},
],
},
features: {},
}
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: {
id: 'app-2',
mode: AppModeEnum.WORKFLOW,
} as never,
}))
expect(result.current.inputFormSchema).toEqual([
expect.objectContaining({
variable: 'attachments',
type: InputVarType.multiFiles,
fileUploadConfig: expect.any(Object),
}),
])
})
it('should return an empty schema when app detail is unavailable', () => {
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: {
id: 'missing-app',
mode: AppModeEnum.CHAT,
} as never,
}))
expect(result.current.inputFormSchema).toEqual([])
})
})

View File

@ -0,0 +1,251 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import DetailHeader from '../index'
const mockSetTargetVersion = vi.fn()
const mockSetVersionPickerOpen = vi.fn()
const mockHandleUpdate = vi.fn()
const mockHandleUpdatedFromMarketplace = vi.fn()
const mockHandleDelete = vi.fn()
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { timezone: 'UTC' },
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
useLocale: () => 'en-US',
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({
data: [{
name: 'tool-plugin/provider-a',
type: 'builtin',
allow_delete: true,
}],
}),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.example.com${path}`,
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ onClick, children }: { onClick?: () => void, children: React.ReactNode }) => (
<button data-testid="close-button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/badge', () => ({
default: ({ text, children }: { text?: React.ReactNode, children?: React.ReactNode }) => (
<div data-testid="badge">{text ?? children}</div>
),
}))
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>,
}))
vi.mock('@/app/components/plugins/plugin-auth', () => ({
AuthCategory: {
tool: 'tool',
},
PluginAuth: ({ pluginPayload }: { pluginPayload: { provider: string } }) => (
<div data-testid="plugin-auth">{pluginPayload.provider}</div>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/operation-dropdown', () => ({
default: ({ detailUrl }: { detailUrl: string }) => <div data-testid="operation-dropdown">{detailUrl}</div>,
}))
vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({
default: ({ onSelect, trigger }: {
onSelect: (value: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void
trigger: React.ReactNode
}) => (
<div>
{trigger}
<button
data-testid="version-select"
onClick={() => onSelect({ version: '2.0.0', unique_identifier: 'uid-2', isDowngrade: true })}
>
select version
</button>
</div>
),
}))
vi.mock('@/app/components/base/badges/verified', () => ({
default: () => <div data-testid="verified" />,
}))
vi.mock('@/app/components/base/deprecation-notice', () => ({
default: () => <div data-testid="deprecation-notice" />,
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src }: { src: string }) => <div data-testid="card-icon">{src}</div>,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName }: { orgName: string }) => <div data-testid="org-info">{orgName}</div>,
}))
vi.mock('@/app/components/plugins/card/base/title', () => ({
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
}))
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
default: () => ({
referenceSetting: {
auto_upgrade: {
upgrade_time_of_day: 0,
},
},
}),
}))
vi.mock('@/app/components/plugins/reference-setting-modal/auto-update-setting/utils', () => ({
convertUTCDaySecondsToLocalSeconds: (value: number) => value,
timeOfDayToDayjs: () => ({
format: () => '10:00 AM',
}),
}))
vi.mock('../components', () => ({
HeaderModals: () => <div data-testid="header-modals" />,
PluginSourceBadge: ({ source }: { source: string }) => <div data-testid="source-badge">{source}</div>,
}))
vi.mock('../hooks', () => ({
useDetailHeaderState: () => ({
modalStates: {
isShowUpdateModal: false,
showUpdateModal: vi.fn(),
hideUpdateModal: vi.fn(),
isShowPluginInfo: false,
showPluginInfo: vi.fn(),
hidePluginInfo: vi.fn(),
isShowDeleteConfirm: false,
showDeleteConfirm: vi.fn(),
hideDeleteConfirm: vi.fn(),
deleting: false,
showDeleting: vi.fn(),
hideDeleting: vi.fn(),
},
versionPicker: {
isShow: false,
setIsShow: mockSetVersionPickerOpen,
targetVersion: {
version: '1.0.0',
unique_identifier: 'uid-1',
},
setTargetVersion: mockSetTargetVersion,
isDowngrade: false,
setIsDowngrade: vi.fn(),
},
hasNewVersion: true,
isAutoUpgradeEnabled: true,
isFromGitHub: false,
isFromMarketplace: true,
}),
usePluginOperations: () => ({
handleUpdate: mockHandleUpdate,
handleUpdatedFromMarketplace: mockHandleUpdatedFromMarketplace,
handleDelete: mockHandleDelete,
}),
}))
const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'plugin-1',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'tool-plugin',
plugin_id: 'tool-plugin',
plugin_unique_identifier: 'tool-plugin@1.0.0',
declaration: {
author: 'acme',
category: PluginCategoryEnum.tool,
name: 'provider-a',
label: { en_US: 'Tool Plugin' },
description: { en_US: 'Tool plugin description' },
icon: 'icon.png',
icon_dark: 'icon-dark.png',
verified: true,
tool: {
identity: {
name: 'provider-a',
},
},
} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '2.0.0',
latest_unique_identifier: 'uid-2',
source: PluginSource.marketplace,
status: 'active',
deprecated_reason: 'Deprecated',
alternative_plugin_id: 'plugin-2',
meta: undefined,
...overrides,
}) as PluginDetail
describe('DetailHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the plugin summary, source badge, auth section, and modal container', () => {
render(<DetailHeader detail={createDetail()} onHide={vi.fn()} onUpdate={vi.fn()} />)
expect(screen.getByTestId('title')).toHaveTextContent('Tool Plugin')
expect(screen.getByTestId('description')).toHaveTextContent('Tool plugin description')
expect(screen.getByTestId('source-badge')).toHaveTextContent('marketplace')
expect(screen.getByTestId('plugin-auth')).toHaveTextContent('tool-plugin/provider-a')
expect(screen.getByTestId('operation-dropdown')).toHaveTextContent('https://marketplace.example.com/plugins/acme/provider-a')
expect(screen.getByTestId('header-modals')).toBeInTheDocument()
})
it('wires version selection, latest update, and hide actions', () => {
const onHide = vi.fn()
render(<DetailHeader detail={createDetail()} onHide={onHide} onUpdate={vi.fn()} />)
fireEvent.click(screen.getByTestId('version-select'))
fireEvent.click(screen.getByText('plugin.detailPanel.operation.update'))
fireEvent.click(screen.getByTestId('close-button'))
expect(mockSetTargetVersion).toHaveBeenCalledWith({
version: '2.0.0',
unique_identifier: 'uid-2',
isDowngrade: true,
})
expect(mockHandleUpdate).toHaveBeenCalledTimes(2)
expect(mockHandleUpdate).toHaveBeenNthCalledWith(1, true)
expect(onHide).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { HeaderModals, PluginSourceBadge } from '../index'
describe('detail-header components index', () => {
it('re-exports header modal components', () => {
expect(HeaderModals).toBeDefined()
expect(PluginSourceBadge).toBeDefined()
})
})

View File

@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { useDetailHeaderState, usePluginOperations } from '../index'
describe('detail-header hooks index', () => {
it('re-exports hook entrypoints', () => {
expect(useDetailHeaderState).toBeTypeOf('function')
expect(usePluginOperations).toBeTypeOf('function')
})
})

View File

@ -0,0 +1,112 @@
import type { FormRefObject } from '@/app/components/base/form/types'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { ApiKeyStep } from '../../hooks/use-common-modal-state'
import {
ConfigurationStepContent,
MultiSteps,
VerifyStepContent,
} from '../modal-steps'
const mockBaseForm = vi.fn()
vi.mock('@/app/components/base/form/components/base', () => ({
BaseForm: ({
formSchemas,
onChange,
}: {
formSchemas: Array<{ name: string }>
onChange?: () => void
}) => {
mockBaseForm(formSchemas)
return (
<div data-testid="base-form">
{formSchemas.map(schema => (
<button key={schema.name} data-testid={`field-${schema.name}`} onClick={onChange}>
{schema.name}
</button>
))}
</div>
)
},
}))
vi.mock('../../../log-viewer', () => ({
default: ({ logs }: { logs: Array<{ id: string, message: string }> }) => (
<div data-testid="log-viewer">
{logs.map(log => <span key={log.id}>{log.message}</span>)}
</div>
),
}))
const subscriptionBuilder: TriggerSubscriptionBuilder = {
id: 'builder-1',
name: 'builder',
provider: 'provider-a',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credentials: {},
endpoint: 'https://example.com/callback',
parameters: {},
properties: {},
workflows_in_use: 0,
}
const formRef = { current: null } as React.RefObject<FormRefObject | null>
describe('modal-steps', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the api key multi step indicator', () => {
render(<MultiSteps currentStep={ApiKeyStep.Verify} />)
expect(screen.getByText('pluginTrigger.modal.steps.verify')).toBeInTheDocument()
expect(screen.getByText('pluginTrigger.modal.steps.configuration')).toBeInTheDocument()
})
it('should render verify step content and forward change events', () => {
const onChange = vi.fn()
render(
<VerifyStepContent
apiKeyCredentialsSchema={[{ name: 'api_key', type: FormTypeEnum.textInput }]}
apiKeyCredentialsFormRef={formRef}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByTestId('field-api_key'))
expect(onChange).toHaveBeenCalled()
})
it('should render manual configuration content with logs', () => {
const onManualPropertiesChange = vi.fn()
render(
<ConfigurationStepContent
createType={SupportedCreationMethods.MANUAL}
subscriptionBuilder={subscriptionBuilder}
subscriptionFormRef={formRef}
autoCommonParametersSchema={[]}
autoCommonParametersFormRef={formRef}
manualPropertiesSchema={[{ name: 'webhook_url', type: FormTypeEnum.textInput }]}
manualPropertiesFormRef={formRef}
onManualPropertiesChange={onManualPropertiesChange}
logs={[{ id: '1', message: 'log-entry', timestamp: 'now', level: 'info', response: { status_code: 200 } } as never]}
pluginId="plugin-id"
pluginName="Plugin A"
provider="provider-a"
/>,
)
fireEvent.click(screen.getByTestId('field-webhook_url'))
expect(onManualPropertiesChange).toHaveBeenCalled()
expect(screen.getByTestId('log-viewer')).toHaveTextContent('log-entry')
})
})

View File

@ -0,0 +1,196 @@
import type { RefObject } from 'react'
import type { FormRefObject } from '@/app/components/base/form/types'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import {
buildSubscriptionPayload,
DEFAULT_FORM_VALUES,
getConfirmButtonText,
getFirstFieldName,
getFormValues,
toSchemaWithTooltip,
useInitializeSubscriptionBuilder,
useSyncSubscriptionEndpoint,
} from '../use-common-modal-state.helpers'
type BuilderResponse = {
subscription_builder: TriggerSubscriptionBuilder
}
const {
mockToastError,
mockIsPrivateOrLocalAddress,
} = vi.hoisted(() => ({
mockToastError: vi.fn(),
mockIsPrivateOrLocalAddress: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('@/utils/urlValidation', () => ({
isPrivateOrLocalAddress: (value: string) => mockIsPrivateOrLocalAddress(value),
}))
describe('use-common-modal-state helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPrivateOrLocalAddress.mockReturnValue(false)
})
it('returns default form values when the form ref is empty', () => {
expect(getFormValues({ current: null })).toEqual(DEFAULT_FORM_VALUES)
})
it('returns form values from the form ref when available', () => {
expect(getFormValues({
current: {
getFormValues: () => ({ values: { subscription_name: 'Sub' }, isCheckValidated: true }),
},
} as unknown as React.RefObject<FormRefObject | null>)).toEqual({
values: { subscription_name: 'Sub' },
isCheckValidated: true,
})
})
it('derives the first field name from values or schema fallback', () => {
expect(getFirstFieldName({ callback_url: 'https://example.com' }, [{ name: 'fallback' }])).toBe('callback_url')
expect(getFirstFieldName({}, [{ name: 'fallback' }])).toBe('fallback')
expect(getFirstFieldName({}, [])).toBe('')
})
it('copies schema help into tooltip fields', () => {
expect(toSchemaWithTooltip([{ name: 'field', help: 'Help text' }])).toEqual([
{
name: 'field',
help: 'Help text',
tooltip: 'Help text',
},
])
})
it('builds subscription payloads for automatic and manual creation', () => {
expect(buildSubscriptionPayload({
provider: 'provider-a',
subscriptionBuilderId: 'builder-a',
createType: SupportedCreationMethods.APIKEY,
subscriptionFormValues: { values: { subscription_name: 'My Sub' }, isCheckValidated: true },
autoCommonParametersSchemaLength: 1,
autoCommonParametersFormValues: { values: { api_key: '123' }, isCheckValidated: true },
manualPropertiesSchemaLength: 0,
manualPropertiesFormValues: undefined,
})).toEqual({
provider: 'provider-a',
subscriptionBuilderId: 'builder-a',
name: 'My Sub',
parameters: { api_key: '123' },
})
expect(buildSubscriptionPayload({
provider: 'provider-a',
subscriptionBuilderId: 'builder-a',
createType: SupportedCreationMethods.MANUAL,
subscriptionFormValues: { values: { subscription_name: 'Manual Sub' }, isCheckValidated: true },
autoCommonParametersSchemaLength: 0,
autoCommonParametersFormValues: undefined,
manualPropertiesSchemaLength: 1,
manualPropertiesFormValues: { values: { custom: 'value' }, isCheckValidated: true },
})).toEqual({
provider: 'provider-a',
subscriptionBuilderId: 'builder-a',
name: 'Manual Sub',
})
})
it('returns null when required validation is missing', () => {
expect(buildSubscriptionPayload({
provider: 'provider-a',
subscriptionBuilderId: 'builder-a',
createType: SupportedCreationMethods.APIKEY,
subscriptionFormValues: { values: {}, isCheckValidated: false },
autoCommonParametersSchemaLength: 1,
autoCommonParametersFormValues: { values: {}, isCheckValidated: true },
manualPropertiesSchemaLength: 0,
manualPropertiesFormValues: undefined,
})).toBeNull()
})
it('builds confirm button text for verify and create states', () => {
const t = (key: string, options?: Record<string, unknown>) => `${options?.ns}.${key}`
expect(getConfirmButtonText({
isVerifyStep: true,
isVerifyingCredentials: false,
isBuilding: false,
t,
})).toBe('pluginTrigger.modal.common.verify')
expect(getConfirmButtonText({
isVerifyStep: false,
isVerifyingCredentials: false,
isBuilding: true,
t,
})).toBe('pluginTrigger.modal.common.creating')
})
it('initializes the subscription builder once when provider is available', async () => {
const createBuilder = vi.fn(async () => ({
subscription_builder: { id: 'builder-1' },
})) as unknown as (params: {
provider: string
credential_type: string
}) => Promise<BuilderResponse>
const setSubscriptionBuilder = vi.fn()
renderHook(() => useInitializeSubscriptionBuilder({
createBuilder,
credentialType: 'oauth',
provider: 'provider-a',
subscriptionBuilder: undefined,
setSubscriptionBuilder,
t: (key: string, options?: Record<string, unknown>) => `${options?.ns}.${key}`,
}))
await waitFor(() => {
expect(createBuilder).toHaveBeenCalledWith({
provider: 'provider-a',
credential_type: 'oauth',
})
expect(setSubscriptionBuilder).toHaveBeenCalledWith({ id: 'builder-1' })
})
})
it('syncs callback endpoint and warnings into the subscription form', async () => {
mockIsPrivateOrLocalAddress.mockReturnValue(true)
const setFieldValue = vi.fn()
const setFields = vi.fn()
const subscriptionFormRef = {
current: {
getForm: () => ({
setFieldValue,
}),
setFields,
},
} as unknown as RefObject<FormRefObject | null>
renderHook(() => useSyncSubscriptionEndpoint({
endpoint: 'http://127.0.0.1/callback',
isConfigurationStep: true,
subscriptionFormRef,
t: (key: string, options?: Record<string, unknown>) => `${options?.ns}.${key}`,
}))
await waitFor(() => {
expect(setFieldValue).toHaveBeenCalledWith('callback_url', 'http://127.0.0.1/callback')
expect(setFields).toHaveBeenCalledWith([{
name: 'callback_url',
warnings: ['pluginTrigger.modal.form.callbackUrl.privateAddressWarning'],
}])
})
})
})

View File

@ -0,0 +1,253 @@
import type { FormRefObject } from '@/app/components/base/form/types'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { ApiKeyStep, useCommonModalState } from '../use-common-modal-state'
type MockPluginDetail = {
plugin_id: string
provider: string
name: string
declaration: {
trigger: {
subscription_schema: Array<{ name: string, type: string, description?: string }>
subscription_constructor: {
credentials_schema: Array<{ name: string, type: string, help?: string }>
parameters: Array<{ name: string, type: string }>
}
}
}
}
const createMockBuilder = (overrides: Partial<TriggerSubscriptionBuilder> = {}): TriggerSubscriptionBuilder => ({
id: 'builder-1',
name: 'builder',
provider: 'provider-a',
credential_type: TriggerCredentialTypeEnum.ApiKey,
credentials: {},
endpoint: 'https://example.com/callback',
parameters: {},
properties: {},
workflows_in_use: 0,
...overrides,
})
const mockDetail: MockPluginDetail = {
plugin_id: 'plugin-id',
provider: 'provider-a',
name: 'Plugin A',
declaration: {
trigger: {
subscription_schema: [{ name: 'webhook_url', type: 'string', description: 'Webhook URL' }],
subscription_constructor: {
credentials_schema: [{ name: 'api_key', type: 'string', help: 'API key help' }],
parameters: [{ name: 'repo_name', type: 'string' }],
},
},
},
}
const mockUsePluginStore = vi.fn(() => mockDetail)
vi.mock('../../../../store', () => ({
usePluginStore: () => mockUsePluginStore(),
}))
const mockRefetch = vi.fn()
vi.mock('../../../use-subscription-list', () => ({
useSubscriptionList: () => ({ refetch: mockRefetch }),
}))
const mockVerifyCredentials = vi.fn()
const mockCreateBuilder = vi.fn()
const mockBuildSubscription = vi.fn()
const mockUpdateBuilder = vi.fn()
let mockIsVerifyingCredentials = false
let mockIsBuilding = false
vi.mock('@/service/use-triggers', () => ({
useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({
mutate: mockVerifyCredentials,
get isPending() { return mockIsVerifyingCredentials },
}),
useCreateTriggerSubscriptionBuilder: () => ({
mutateAsync: mockCreateBuilder,
}),
useBuildTriggerSubscription: () => ({
mutate: mockBuildSubscription,
get isPending() { return mockIsBuilding },
}),
useUpdateTriggerSubscriptionBuilder: () => ({
mutate: mockUpdateBuilder,
}),
useTriggerSubscriptionBuilderLogs: () => ({
data: { logs: [] },
}),
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (message: string) => mockToastNotify({ type: 'success', message }),
error: (message: string) => mockToastNotify({ type: 'error', message }),
},
}))
const mockParsePluginErrorMessage = vi.fn().mockResolvedValue(null)
vi.mock('@/utils/error-parser', () => ({
parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args),
}))
vi.mock('@/utils/urlValidation', () => ({
isPrivateOrLocalAddress: vi.fn().mockReturnValue(false),
}))
const createFormRef = ({
values = {},
isCheckValidated = true,
}: {
values?: Record<string, unknown>
isCheckValidated?: boolean
} = {}): FormRefObject => ({
getFormValues: vi.fn().mockReturnValue({ values, isCheckValidated }),
setFields: vi.fn(),
getForm: vi.fn().mockReturnValue({
setFieldValue: vi.fn(),
}),
} as unknown as FormRefObject)
describe('useCommonModalState', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsVerifyingCredentials = false
mockIsBuilding = false
mockCreateBuilder.mockResolvedValue({
subscription_builder: createMockBuilder(),
})
})
it('should initialize api key builders and expose verify step state', async () => {
const { result } = renderHook(() => useCommonModalState({
createType: SupportedCreationMethods.APIKEY,
onClose: vi.fn(),
}))
await waitFor(() => {
expect(result.current.subscriptionBuilder?.id).toBe('builder-1')
})
expect(mockCreateBuilder).toHaveBeenCalledWith({
provider: 'provider-a',
credential_type: TriggerCredentialTypeEnum.ApiKey,
})
expect(result.current.currentStep).toBe(ApiKeyStep.Verify)
expect(result.current.apiKeyCredentialsSchema[0]).toMatchObject({
name: 'api_key',
tooltip: 'API key help',
})
})
it('should verify credentials and advance to configuration step', async () => {
mockVerifyCredentials.mockImplementation((_payload, options) => {
options?.onSuccess?.()
})
const builder = createMockBuilder()
const { result } = renderHook(() => useCommonModalState({
createType: SupportedCreationMethods.APIKEY,
builder,
onClose: vi.fn(),
}))
const credentialsFormRef = result.current.formRefs.apiKeyCredentialsFormRef as { current: FormRefObject | null }
credentialsFormRef.current = createFormRef({
values: { api_key: 'secret' },
})
act(() => {
result.current.handleVerify()
})
expect(mockVerifyCredentials).toHaveBeenCalledWith({
provider: 'provider-a',
subscriptionBuilderId: builder.id,
credentials: { api_key: 'secret' },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(result.current.currentStep).toBe(ApiKeyStep.Configuration)
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
})
it('should build subscriptions with validated automatic parameters', () => {
const onClose = vi.fn()
const builder = createMockBuilder()
const { result } = renderHook(() => useCommonModalState({
createType: SupportedCreationMethods.APIKEY,
builder,
onClose,
}))
const subscriptionFormRef = result.current.formRefs.subscriptionFormRef as { current: FormRefObject | null }
const autoParamsFormRef = result.current.formRefs.autoCommonParametersFormRef as { current: FormRefObject | null }
subscriptionFormRef.current = createFormRef({
values: { subscription_name: 'Subscription A' },
})
autoParamsFormRef.current = createFormRef({
values: { repo_name: 'repo-a' },
})
act(() => {
result.current.handleCreate()
})
expect(mockBuildSubscription).toHaveBeenCalledWith({
provider: 'provider-a',
subscriptionBuilderId: builder.id,
name: 'Subscription A',
parameters: { repo_name: 'repo-a' },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
})
it('should debounce manual property updates', async () => {
vi.useFakeTimers()
const builder = createMockBuilder({
credential_type: TriggerCredentialTypeEnum.Unauthorized,
})
const { result } = renderHook(() => useCommonModalState({
createType: SupportedCreationMethods.MANUAL,
builder,
onClose: vi.fn(),
}))
const manualFormRef = result.current.formRefs.manualPropertiesFormRef as { current: FormRefObject | null }
manualFormRef.current = createFormRef({
values: { webhook_url: 'https://hook.example.com' },
isCheckValidated: true,
})
act(() => {
result.current.handleManualPropertiesChange()
vi.advanceTimersByTime(500)
})
expect(mockUpdateBuilder).toHaveBeenCalledWith({
provider: 'provider-a',
subscriptionBuilderId: builder.id,
properties: { webhook_url: 'https://hook.example.com' },
}, expect.objectContaining({
onError: expect.any(Function),
}))
vi.useRealTimers()
})
})

View File

@ -0,0 +1,180 @@
'use client'
import type { Dispatch, SetStateAction } from 'react'
import type { FormRefObject } from '@/app/components/base/form/types'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers'
import { useEffect, useRef } from 'react'
import { toast } from '@/app/components/base/ui/toast'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
type FormValuesResult = {
values: Record<string, unknown>
isCheckValidated: boolean
}
type InitializeBuilderParams = {
createBuilder: (params: {
provider: string
credential_type: string
}) => Promise<{ subscription_builder: TriggerSubscriptionBuilder }>
credentialType: string
provider?: string
subscriptionBuilder?: TriggerSubscriptionBuilder
setSubscriptionBuilder: Dispatch<SetStateAction<TriggerSubscriptionBuilder | undefined>>
t: (key: string, options?: Record<string, unknown>) => string
}
type SyncEndpointParams = {
endpoint?: string
isConfigurationStep: boolean
subscriptionFormRef: React.RefObject<FormRefObject | null>
t: (key: string, options?: Record<string, unknown>) => string
}
type BuildPayloadParams = {
provider: string
subscriptionBuilderId: string
createType: SupportedCreationMethods
subscriptionFormValues?: FormValuesResult
autoCommonParametersSchemaLength: number
autoCommonParametersFormValues?: FormValuesResult
manualPropertiesSchemaLength: number
manualPropertiesFormValues?: FormValuesResult
}
export const DEFAULT_FORM_VALUES: FormValuesResult = { values: {}, isCheckValidated: false }
export const getFormValues = (formRef: React.RefObject<FormRefObject | null>) => {
return formRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
}
export const getFirstFieldName = (
values: Record<string, unknown>,
fallbackSchema: Array<{ name: string }>,
) => {
return Object.keys(values)[0] || fallbackSchema[0]?.name || ''
}
export const toSchemaWithTooltip = <T extends { help?: unknown, name: string }>(schemas: T[] = []) => {
return schemas.map(schema => ({
...schema,
tooltip: schema.help,
}))
}
export const buildSubscriptionPayload = ({
provider,
subscriptionBuilderId,
createType,
subscriptionFormValues,
autoCommonParametersSchemaLength,
autoCommonParametersFormValues,
manualPropertiesSchemaLength,
manualPropertiesFormValues,
}: BuildPayloadParams): BuildTriggerSubscriptionPayload | null => {
if (!subscriptionFormValues?.isCheckValidated)
return null
const subscriptionNameValue = subscriptionFormValues.values.subscription_name as string
const params: BuildTriggerSubscriptionPayload = {
provider,
subscriptionBuilderId,
name: subscriptionNameValue,
}
if (createType !== SupportedCreationMethods.MANUAL) {
if (!autoCommonParametersSchemaLength)
return params
if (!autoCommonParametersFormValues?.isCheckValidated)
return null
params.parameters = autoCommonParametersFormValues.values
return params
}
if (manualPropertiesSchemaLength && !manualPropertiesFormValues?.isCheckValidated)
return null
return params
}
export const getConfirmButtonText = ({
isVerifyStep,
isVerifyingCredentials,
isBuilding,
t,
}: {
isVerifyStep: boolean
isVerifyingCredentials: boolean
isBuilding: boolean
t: (key: string, options?: Record<string, unknown>) => string
}) => {
if (isVerifyStep) {
return isVerifyingCredentials
? t('modal.common.verifying', { ns: 'pluginTrigger' })
: t('modal.common.verify', { ns: 'pluginTrigger' })
}
return isBuilding
? t('modal.common.creating', { ns: 'pluginTrigger' })
: t('modal.common.create', { ns: 'pluginTrigger' })
}
export const useInitializeSubscriptionBuilder = ({
createBuilder,
credentialType,
provider,
subscriptionBuilder,
setSubscriptionBuilder,
t,
}: InitializeBuilderParams) => {
const isInitializedRef = useRef(false)
useEffect(() => {
const initializeBuilder = async () => {
isInitializedRef.current = true
try {
const response = await createBuilder({
provider: provider || '',
credential_type: credentialType,
})
setSubscriptionBuilder(response.subscription_builder)
}
catch (error) {
console.error('createBuilder error:', error)
toast.error(t('modal.errors.createFailed', { ns: 'pluginTrigger' }))
}
}
if (!isInitializedRef.current && !subscriptionBuilder && provider)
initializeBuilder()
}, [subscriptionBuilder, provider, credentialType, createBuilder, setSubscriptionBuilder, t])
}
export const useSyncSubscriptionEndpoint = ({
endpoint,
isConfigurationStep,
subscriptionFormRef,
t,
}: SyncEndpointParams) => {
useEffect(() => {
if (!endpoint || !subscriptionFormRef.current || !isConfigurationStep)
return
const form = subscriptionFormRef.current.getForm()
if (form)
form.setFieldValue('callback_url', endpoint)
const warnings = isPrivateOrLocalAddress(endpoint)
? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })]
: []
subscriptionFormRef.current.setFields([{
name: 'callback_url',
warnings,
}])
}, [endpoint, isConfigurationStep, subscriptionFormRef, t])
}

View File

@ -3,7 +3,6 @@ import type { SimpleDetail } from '../../../store'
import type { SchemaItem } from '../components/modal-steps'
import type { FormRefObject } from '@/app/components/base/form/types'
import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers'
import { debounce } from 'es-toolkit/compat'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -18,9 +17,17 @@ import {
useVerifyAndUpdateTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
import { usePluginStore } from '../../../store'
import { useSubscriptionList } from '../../use-subscription-list'
import {
buildSubscriptionPayload,
getConfirmButtonText,
getFirstFieldName,
getFormValues,
toSchemaWithTooltip,
useInitializeSubscriptionBuilder,
useSyncSubscriptionEndpoint,
} from './use-common-modal-state.helpers'
// ============================================================================
// Types
@ -85,8 +92,6 @@ type UseCommonModalStateReturn = {
handleApiKeyCredentialsChange: () => void
}
const DEFAULT_FORM_VALUES = { values: {}, isCheckValidated: false }
// ============================================================================
// Hook Implementation
// ============================================================================
@ -105,7 +110,6 @@ export const useCommonModalState = ({
createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration,
)
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
const isInitializedRef = useRef(false)
// Form refs
const manualPropertiesFormRef = useRef<FormRefObject>(null)
@ -123,12 +127,9 @@ export const useCommonModalState = ({
const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || []
const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || []
const apiKeyCredentialsSchema = useMemo(() => {
const apiKeyCredentialsSchema = useMemo<SchemaItem[]>(() => {
const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || []
return rawSchema.map(schema => ({
...schema,
tooltip: schema.help,
}))
return toSchemaWithTooltip(rawSchema) as SchemaItem[]
}, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema])
// Log data for manual mode
@ -162,25 +163,14 @@ export const useCommonModalState = ({
[updateBuilder, t],
)
// Initialize builder
useEffect(() => {
const initializeBuilder = async () => {
isInitializedRef.current = true
try {
const response = await createBuilder({
provider: detail?.provider || '',
credential_type: CREDENTIAL_TYPE_MAP[createType],
})
setSubscriptionBuilder(response.subscription_builder)
}
catch (error) {
console.error('createBuilder error:', error)
toast.error(t('modal.errors.createFailed', { ns: 'pluginTrigger' }))
}
}
if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider)
initializeBuilder()
}, [subscriptionBuilder, detail?.provider, createType, createBuilder, t])
useInitializeSubscriptionBuilder({
createBuilder,
credentialType: CREDENTIAL_TYPE_MAP[createType],
provider: detail?.provider,
subscriptionBuilder,
setSubscriptionBuilder,
t,
})
// Cleanup debounced function
useEffect(() => {
@ -189,24 +179,12 @@ export const useCommonModalState = ({
}
}, [debouncedUpdate])
// Update endpoint in form when endpoint changes
useEffect(() => {
if (!subscriptionBuilder?.endpoint || !subscriptionFormRef.current || currentStep !== ApiKeyStep.Configuration)
return
const form = subscriptionFormRef.current.getForm()
if (form)
form.setFieldValue('callback_url', subscriptionBuilder.endpoint)
const warnings = isPrivateOrLocalAddress(subscriptionBuilder.endpoint)
? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })]
: []
subscriptionFormRef.current?.setFields([{
name: 'callback_url',
warnings,
}])
}, [subscriptionBuilder?.endpoint, currentStep, t])
useSyncSubscriptionEndpoint({
endpoint: subscriptionBuilder?.endpoint,
isConfigurationStep: currentStep === ApiKeyStep.Configuration,
subscriptionFormRef,
t,
})
// Handle manual properties change
const handleManualPropertiesChange = useCallback(() => {
@ -237,7 +215,7 @@ export const useCommonModalState = ({
return
}
const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
const apiKeyCredentialsFormValues = getFormValues(apiKeyCredentialsFormRef)
const credentials = apiKeyCredentialsFormValues.values
if (!Object.keys(credentials).length) {
@ -245,8 +223,10 @@ export const useCommonModalState = ({
return
}
const credentialFieldName = getFirstFieldName(credentials, apiKeyCredentialsSchema)
apiKeyCredentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
name: credentialFieldName,
errors: [],
}])
@ -264,13 +244,13 @@ export const useCommonModalState = ({
onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' })
apiKeyCredentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
name: credentialFieldName,
errors: [errorMessage],
}])
},
},
)
}, [detail?.provider, subscriptionBuilder?.id, verifyCredentials, t])
}, [apiKeyCredentialsSchema, detail?.provider, subscriptionBuilder?.id, verifyCredentials, t])
// Handle create
const handleCreate = useCallback(() => {
@ -279,31 +259,19 @@ export const useCommonModalState = ({
return
}
const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({})
if (!subscriptionFormValues?.isCheckValidated)
return
const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string
const params: BuildTriggerSubscriptionPayload = {
const params = buildSubscriptionPayload({
provider: detail?.provider || '',
subscriptionBuilderId: subscriptionBuilder.id,
name: subscriptionNameValue,
}
createType,
subscriptionFormValues: getFormValues(subscriptionFormRef),
autoCommonParametersSchemaLength: autoCommonParametersSchema.length,
autoCommonParametersFormValues: getFormValues(autoCommonParametersFormRef),
manualPropertiesSchemaLength: manualPropertiesSchema.length,
manualPropertiesFormValues: getFormValues(manualPropertiesFormRef),
})
if (createType !== SupportedCreationMethods.MANUAL) {
if (autoCommonParametersSchema.length > 0) {
const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
if (!autoCommonParametersFormValues?.isCheckValidated)
return
params.parameters = autoCommonParametersFormValues.values
}
}
else if (manualPropertiesSchema.length > 0) {
const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
if (!manualFormValues?.isCheckValidated)
return
}
if (!params)
return
buildSubscription(
params,
@ -341,14 +309,12 @@ export const useCommonModalState = ({
// Confirm button text
const confirmButtonText = useMemo(() => {
if (currentStep === ApiKeyStep.Verify) {
return isVerifyingCredentials
? t('modal.common.verifying', { ns: 'pluginTrigger' })
: t('modal.common.verify', { ns: 'pluginTrigger' })
}
return isBuilding
? t('modal.common.creating', { ns: 'pluginTrigger' })
: t('modal.common.create', { ns: 'pluginTrigger' })
return getConfirmButtonText({
isVerifyStep: currentStep === ApiKeyStep.Verify,
isVerifyingCredentials,
isBuilding,
t,
})
}, [currentStep, isVerifyingCredentials, isBuilding, t])
return {

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import {
SchemaModal,
ToolAuthorizationSection,
ToolBaseForm,
ToolCredentialsForm,
ToolItem,
ToolSettingsPanel,
ToolTrigger,
} from '../index'
describe('tool-selector components index', () => {
it('re-exports the tool selector components', () => {
expect(SchemaModal).toBeDefined()
expect(ToolAuthorizationSection).toBeDefined()
expect(ToolBaseForm).toBeDefined()
expect(ToolCredentialsForm).toBeDefined()
expect(ToolItem).toBeDefined()
expect(ToolSettingsPanel).toBeDefined()
expect(ToolTrigger).toBeDefined()
})
})

View File

@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { VarType } from '@/app/components/workflow/types'
import {
createEmptyAppValue,
createFilterVar,
createPickerProps,
createReasoningFormContext,
getFieldFlags,
getFieldTitle,
getVarKindType,
getVisibleSelectOptions,
mergeReasoningValue,
resolveTargetVarType,
updateInputAutoState,
updateReasoningValue,
updateVariableSelectorValue,
updateVariableTypeValue,
} from '../reasoning-config-form.helpers'
describe('reasoning-config-form helpers', () => {
it('maps schema types to variable-kind types and target variable types', () => {
expect(getVarKindType(FormTypeEnum.files)).toBe(VarKindType.variable)
expect(getVarKindType(FormTypeEnum.textNumber)).toBe(VarKindType.constant)
expect(getVarKindType(FormTypeEnum.textInput)).toBe(VarKindType.mixed)
expect(getVarKindType(FormTypeEnum.dynamicSelect)).toBeUndefined()
expect(resolveTargetVarType(FormTypeEnum.textInput)).toBe(VarType.string)
expect(resolveTargetVarType(FormTypeEnum.textNumber)).toBe(VarType.number)
expect(resolveTargetVarType(FormTypeEnum.files)).toBe(VarType.arrayFile)
expect(resolveTargetVarType(FormTypeEnum.file)).toBe(VarType.file)
expect(resolveTargetVarType(FormTypeEnum.checkbox)).toBe(VarType.boolean)
expect(resolveTargetVarType(FormTypeEnum.object)).toBe(VarType.object)
expect(resolveTargetVarType(FormTypeEnum.array)).toBe(VarType.arrayObject)
})
it('creates variable filters for supported field types', () => {
const numberFilter = createFilterVar(FormTypeEnum.textNumber)
const stringFilter = createFilterVar(FormTypeEnum.textInput)
const fileFilter = createFilterVar(FormTypeEnum.files)
expect(numberFilter?.({ type: VarType.number } as never)).toBe(true)
expect(numberFilter?.({ type: VarType.string } as never)).toBe(false)
expect(stringFilter?.({ type: VarType.secret } as never)).toBe(true)
expect(fileFilter?.({ type: VarType.arrayFile } as never)).toBe(true)
})
it('filters select options based on show_on conditions', () => {
const options = [
{
value: 'one',
label: { en_US: 'One', zh_Hans: 'One' },
show_on: [],
},
{
value: 'two',
label: { en_US: 'Two', zh_Hans: 'Two' },
show_on: [{ variable: 'mode', value: 'advanced' }],
},
]
expect(getVisibleSelectOptions(options as never, {
mode: { value: { value: 'advanced' } },
}, 'en_US')).toEqual([
{ value: 'one', name: 'One' },
{ value: 'two', name: 'Two' },
])
expect(getVisibleSelectOptions(options as never, {
mode: { value: { value: 'basic' } },
}, 'en_US')).toEqual([
{ value: 'one', name: 'One' },
])
})
it('updates reasoning values for auto, constant, variable, and merged states', () => {
const value = {
prompt: {
value: {
type: VarKindType.constant,
value: 'hello',
},
auto: 0 as const,
},
}
expect(updateInputAutoState(value, 'prompt', true, FormTypeEnum.textInput)).toEqual({
prompt: {
value: null,
auto: 1,
},
})
expect(updateVariableTypeValue(value, 'prompt', VarKindType.variable, '')).toEqual({
prompt: {
value: {
type: VarKindType.variable,
value: '',
},
auto: 0,
},
})
expect(updateReasoningValue(value, 'prompt', FormTypeEnum.textInput, 'updated')).toEqual({
prompt: {
value: {
type: VarKindType.mixed,
value: 'updated',
},
auto: 0,
},
})
expect(mergeReasoningValue(value, 'prompt', { extra: true })).toEqual({
prompt: {
value: {
type: VarKindType.constant,
value: 'hello',
extra: true,
},
auto: 0,
},
})
expect(updateVariableSelectorValue(value, 'prompt', ['node', 'field'])).toEqual({
prompt: {
value: {
type: VarKindType.variable,
value: ['node', 'field'],
},
auto: 0,
},
})
})
it('derives field flags and picker props from schema types', () => {
expect(getFieldFlags(FormTypeEnum.object, { type: VarKindType.constant })).toEqual(expect.objectContaining({
isObject: true,
isShowJSONEditor: true,
showTypeSwitch: true,
isConstant: true,
}))
expect(createPickerProps({
type: FormTypeEnum.select,
value: {},
language: 'en_US',
schema: {
options: [
{
value: 'one',
label: { en_US: 'One', zh_Hans: 'One' },
show_on: [],
},
],
} as never,
})).toEqual(expect.objectContaining({
targetVarType: VarType.string,
selectItems: [{ value: 'one', name: 'One' }],
}))
})
it('provides label helpers and empty defaults', () => {
expect(getFieldTitle({ en_US: 'Prompt', zh_Hans: 'Prompt' }, 'en_US')).toBe('Prompt')
expect(createEmptyAppValue()).toEqual({
app_id: '',
inputs: {},
files: [],
})
expect(createReasoningFormContext({
availableNodes: [{ id: 'node-1' }] as never,
nodeId: 'node-current',
nodeOutputVars: [{ nodeId: 'node-1' }] as never,
})).toEqual({
availableNodes: [{ id: 'node-1' }],
nodeId: 'node-current',
nodeOutputVars: [{ nodeId: 'node-1' }],
})
})
})

View File

@ -0,0 +1,340 @@
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import ReasoningConfigForm from '../reasoning-config-form'
vi.mock('@/app/components/base/input', () => ({
default: ({ value, onChange }: { value?: string, onChange: (e: { target: { value: string } }) => void }) => (
<input data-testid="number-input" value={value} onChange={e => onChange({ target: { value: e.target.value } })} />
),
}))
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: ({
items,
onSelect,
}: {
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
}) => (
<div>
{items.map(item => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect({ value: item.value })}>
{item.name}
</button>
))}
</div>
),
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => (
<button data-testid="auto-switch" onClick={() => onChange(!value)}>
{value ? 'on' : 'off'}
</button>
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
default: ({ onSelect }: { onSelect: (value: Record<string, unknown>) => void }) => (
<button
data-testid="app-selector"
onClick={() => onSelect({ app_id: 'app-1', inputs: { topic: 'hello' } })}
>
Select App
</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
default: ({ setModel }: { setModel: (value: Record<string, unknown>) => void }) => (
<button data-testid="model-selector" onClick={() => setModel({ model: 'gpt-4.1' })}>
Select Model
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button data-testid="code-editor" onClick={() => onChange('{\"foo\":\"bar\"}')}>
Update JSON
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/form-input-boolean', () => ({
default: ({ onChange }: { onChange: (value: boolean) => void }) => (
<button data-testid="boolean-input" onClick={() => onChange(true)}>
Set Boolean
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/form-input-type-switch', () => ({
default: ({ onChange }: { onChange: (value: VarKindType) => void }) => (
<button data-testid="type-switch" onClick={() => onChange(VarKindType.variable)}>
Switch Type
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button data-testid="var-picker" onClick={() => onChange(['node', 'field'] as unknown as string)}>
Pick Variable
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button data-testid="mixed-input" onClick={() => onChange('updated-text')}>
Update Text
</button>
),
}))
vi.mock('../schema-modal', () => ({
default: ({ isShow, rootName, onClose }: { isShow: boolean, rootName: string, onClose: () => void }) => (
isShow
? (
<div data-testid="schema-modal">
<span>{rootName}</span>
<button data-testid="close-schema" onClick={onClose}>Close</button>
</div>
)
: null
),
}))
const createSchema = (overrides: Partial<ToolFormSchema> = {}): ToolFormSchema => ({
variable: 'field',
type: FormTypeEnum.textInput,
default: '',
required: false,
label: { en_US: 'Field', zh_Hans: '字段' },
tooltip: { en_US: 'Tooltip', zh_Hans: '提示' },
scope: 'all',
url: '',
input_schema: {},
placeholder: { en_US: 'Placeholder', zh_Hans: '占位符' },
options: [],
...overrides,
} as ToolFormSchema)
describe('ReasoningConfigForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should toggle automatic values for text fields', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
field: {
auto: 0,
value: { type: VarKindType.mixed, value: 'hello' },
},
}}
onChange={onChange}
schemas={[createSchema()]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
fireEvent.click(screen.getByTestId('auto-switch'))
expect(onChange).toHaveBeenCalledWith({
field: {
auto: 1,
value: null,
},
})
})
it('should update mixed text and variable types', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
count: {
auto: 0,
value: { type: VarKindType.constant, value: '1' },
},
field: {
auto: 0,
value: { type: VarKindType.mixed, value: 'hello' },
},
}}
onChange={onChange}
schemas={[
createSchema({ variable: 'field', type: FormTypeEnum.textInput }),
createSchema({ variable: 'count', type: FormTypeEnum.textNumber, default: '5', label: { en_US: 'Count', zh_Hans: '数量' } }),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
fireEvent.click(screen.getByTestId('mixed-input'))
fireEvent.click(screen.getByTestId('type-switch'))
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
field: {
auto: 0,
value: { type: VarKindType.mixed, value: 'updated-text' },
},
}))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
count: {
auto: 0,
value: { type: VarKindType.variable, value: '' },
},
}))
})
it('should open schema modal for object fields and support app selection', () => {
const onChange = vi.fn()
const { container } = render(
<ReasoningConfigForm
value={{
app: {
auto: 0,
value: { type: VarKindType.constant, value: null },
},
config: {
auto: 0,
value: { type: VarKindType.constant, value: '{}' },
},
}}
onChange={onChange}
schemas={[
createSchema({
variable: 'config',
type: FormTypeEnum.object,
input_schema: { type: Type.object, properties: {}, additionalProperties: false },
label: { en_US: 'Config', zh_Hans: '配置' },
}),
createSchema({
variable: 'app',
type: FormTypeEnum.appSelector,
label: { en_US: 'App', zh_Hans: '应用' },
}),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
fireEvent.click(container.querySelector('div.ml-0\\.5.cursor-pointer')!)
expect(screen.getByTestId('schema-modal')).toHaveTextContent('Config')
fireEvent.click(screen.getByTestId('close-schema'))
fireEvent.click(screen.getByTestId('app-selector'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
app: {
auto: 0,
value: {
type: undefined,
value: { app_id: 'app-1', inputs: { topic: 'hello' } },
},
},
}))
})
it('should merge model selector values into the current field value', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
model: {
auto: 0,
value: { provider: 'openai' },
},
}}
onChange={onChange}
schemas={[
createSchema({
variable: 'model',
type: FormTypeEnum.modelSelector,
label: { en_US: 'Model', zh_Hans: '模型' },
}),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
fireEvent.click(screen.getByTestId('model-selector'))
expect(onChange).toHaveBeenCalledWith({
model: {
auto: 0,
value: {
provider: 'openai',
model: 'gpt-4.1',
},
},
})
})
it('should update file fields from the variable selector', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
files: {
auto: 0,
value: { type: VarKindType.variable, value: [] },
},
}}
onChange={onChange}
schemas={[
createSchema({
variable: 'files',
type: FormTypeEnum.files,
label: { en_US: 'Files', zh_Hans: '文件' },
}),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
fireEvent.click(screen.getByTestId('var-picker'))
expect(onChange).toHaveBeenCalledWith({
files: {
auto: 0,
value: {
type: VarKindType.variable,
value: ['node', 'field'],
},
},
})
})
})

View File

@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SchemaModal from '../schema-modal'
vi.mock('@/app/components/base/modal', () => ({
default: ({
children,
isShow,
}: {
children: React.ReactNode
isShow: boolean
}) => isShow ? <div data-testid="modal">{children}</div> : null,
}))
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor', () => ({
default: ({ rootName }: { rootName: string }) => <div data-testid="visual-editor">{rootName}</div>,
}))
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context', () => ({
MittProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
VisualEditorContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
describe('SchemaModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not render content when hidden', () => {
render(
<SchemaModal
isShow={false}
schema={{} as never}
rootName="root"
onClose={vi.fn()}
/>,
)
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('renders the schema title and closes when the close control is clicked', () => {
const onClose = vi.fn()
render(
<SchemaModal
isShow
schema={{ type: 'object' } as never}
rootName="response"
onClose={onClose}
/>,
)
expect(screen.getByText('workflow.nodes.agent.parameterSchema')).toBeInTheDocument()
expect(screen.getByTestId('visual-editor')).toHaveTextContent('response')
const closeButton = document.body.querySelector('div.absolute.right-5.top-5')
fireEvent.click(closeButton!)
expect(onClose).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,64 @@
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { CollectionType } from '@/app/components/tools/types'
import ToolAuthorizationSection from '../tool-authorization-section'
vi.mock('@/app/components/plugins/plugin-auth', () => ({
AuthCategory: {
tool: 'tool',
},
PluginAuthInAgent: ({ pluginPayload, credentialId }: {
pluginPayload: { provider: string, providerType: string }
credentialId?: string
}) => (
<div data-testid="plugin-auth-in-agent">
{pluginPayload.provider}
:
{pluginPayload.providerType}
:
{credentialId}
</div>
),
}))
const createProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
name: 'provider-a',
type: CollectionType.builtIn,
allow_delete: true,
...overrides,
}) as ToolWithProvider
describe('ToolAuthorizationSection', () => {
it('returns null for providers that are not removable built-ins', () => {
const { container, rerender } = render(
<ToolAuthorizationSection
currentProvider={createProvider({ type: CollectionType.custom })}
onAuthorizationItemClick={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
rerender(
<ToolAuthorizationSection
currentProvider={createProvider({ allow_delete: false })}
onAuthorizationItemClick={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('renders the authorization panel for removable built-in providers', () => {
render(
<ToolAuthorizationSection
currentProvider={createProvider()}
credentialId="credential-1"
onAuthorizationItemClick={vi.fn()}
/>,
)
expect(screen.getByTestId('plugin-auth-in-agent')).toHaveTextContent('provider-a:builtin:credential-1')
})
})

View File

@ -0,0 +1,130 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ToolItem from '../tool-item'
let mcpAllowed = true
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
useMCPToolAvailability: () => ({
allowed: mcpAllowed,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip', () => ({
default: () => <div data-testid="mcp-tooltip">mcp unavailable</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: ({ onSuccess }: { onSuccess: () => void }) => (
<button onClick={onSuccess}>install plugin</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
SwitchPluginVersion: ({ onChange }: { onChange: () => void }) => (
<button onClick={onChange}>switch version</button>
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
children,
popupContent,
}: {
children: React.ReactNode
popupContent: React.ReactNode
}) => (
<div>
{children}
<div>{popupContent}</div>
</div>
),
}))
describe('ToolItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mcpAllowed = true
})
it('shows auth status actions for no-auth and auth-removed states', () => {
const { rerender } = render(
<ToolItem
open={false}
toolLabel="Search Tool"
providerName="acme/search"
noAuth
/>,
)
expect(screen.getByText('tools.notAuthorized')).toBeInTheDocument()
rerender(
<ToolItem
open={false}
toolLabel="Search Tool"
providerName="acme/search"
authRemoved
/>,
)
expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument()
})
it('surfaces install and version mismatch recovery actions', () => {
const onInstall = vi.fn()
const { rerender } = render(
<ToolItem
open={false}
toolLabel="Search Tool"
providerName="acme/search"
uninstalled
installInfo="plugin@2.0.0"
onInstall={onInstall}
/>,
)
fireEvent.click(screen.getByText('install plugin'))
expect(onInstall).toHaveBeenCalledTimes(1)
rerender(
<ToolItem
open={false}
toolLabel="Search Tool"
providerName="acme/search"
versionMismatch
installInfo="plugin@2.0.0"
onInstall={onInstall}
/>,
)
fireEvent.click(screen.getByText('switch version'))
expect(onInstall).toHaveBeenCalledTimes(2)
})
it('blocks unsupported MCP tools and still exposes error state', () => {
mcpAllowed = false
const { rerender } = render(
<ToolItem
open={false}
toolLabel="Search Tool"
providerName="acme/search"
isMCPTool
/>,
)
expect(screen.getByTestId('mcp-tooltip')).toBeInTheDocument()
rerender(
<ToolItem
open={false}
toolLabel="Search Tool"
providerName="acme/search"
isError
errorTip="tool failed"
/>,
)
expect(screen.getByText('tool failed')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,100 @@
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ToolSettingsPanel from '../tool-settings-panel'
vi.mock('@/app/components/base/tab-slider-plain', () => ({
default: ({
options,
onChange,
}: {
options: Array<{ value: string, text: string }>
onChange: (value: string) => void
}) => (
<div data-testid="tab-slider">
{options.map(option => (
<button key={option.value} onClick={() => onChange(option.value)}>{option.text}</button>
))}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/tool/components/tool-form', () => ({
default: ({ schema }: { schema: Array<{ name: string }> }) => <div data-testid="tool-form">{schema.map(item => item.name).join(',')}</div>,
}))
vi.mock('../reasoning-config-form', () => ({
default: ({ schemas }: { schemas: Array<{ name: string }> }) => <div data-testid="reasoning-config-form">{schemas.map(item => item.name).join(',')}</div>,
}))
const baseProps = {
nodeId: 'node-1',
currType: 'settings' as const,
settingsFormSchemas: [{ name: 'api_key' }] as never[],
paramsFormSchemas: [{ name: 'temperature' }] as never[],
settingsValue: {},
showTabSlider: true,
userSettingsOnly: false,
reasoningConfigOnly: false,
nodeOutputVars: [],
availableNodes: [],
onCurrTypeChange: vi.fn(),
onSettingsFormChange: vi.fn(),
onParamsFormChange: vi.fn(),
currentProvider: {
is_team_authorization: true,
} as ToolWithProvider,
}
describe('ToolSettingsPanel', () => {
it('returns null when the provider is not team-authorized or has no forms', () => {
const { container, rerender } = render(
<ToolSettingsPanel
{...baseProps}
currentProvider={{ is_team_authorization: false } as ToolWithProvider}
/>,
)
expect(container).toBeEmptyDOMElement()
rerender(
<ToolSettingsPanel
{...baseProps}
settingsFormSchemas={[]}
paramsFormSchemas={[]}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('renders the settings form and lets the tab slider switch to params', () => {
const onCurrTypeChange = vi.fn()
render(
<ToolSettingsPanel
{...baseProps}
onCurrTypeChange={onCurrTypeChange}
/>,
)
expect(screen.getByTestId('tool-form')).toHaveTextContent('api_key')
fireEvent.click(screen.getByText('plugin.detailPanel.toolSelector.params'))
expect(onCurrTypeChange).toHaveBeenCalledWith('params')
})
it('renders params tips and the reasoning config form for params-only views', () => {
render(
<ToolSettingsPanel
{...baseProps}
currType="params"
settingsFormSchemas={[]}
userSettingsOnly={false}
reasoningConfigOnly
/>,
)
expect(screen.getAllByText('plugin.detailPanel.toolSelector.paramsTip1')).toHaveLength(2)
expect(screen.getByTestId('reasoning-config-form')).toHaveTextContent('temperature')
})
})

View File

@ -0,0 +1,38 @@
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ToolTrigger from '../tool-trigger'
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
describe('ToolTrigger', () => {
it('renders the placeholder for the unconfigured state', () => {
render(<ToolTrigger open={false} />)
expect(screen.getByText('plugin.detailPanel.toolSelector.placeholder')).toBeInTheDocument()
})
it('renders the selected provider icon and tool label', () => {
render(
<ToolTrigger
open
provider={{ icon: 'tool-icon' } as ToolWithProvider}
value={{
provider_name: 'provider-a',
tool_name: 'Search Tool',
}}
/>,
)
expect(screen.getByTestId('block-icon')).toBeInTheDocument()
expect(screen.getByText('Search Tool')).toBeInTheDocument()
})
it('switches to the configure placeholder when requested', () => {
render(<ToolTrigger open={false} isConfigure />)
expect(screen.getByText('plugin.detailPanel.configureTool')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,233 @@
import type { Node } from 'reactflow'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { VarType } from '@/app/components/workflow/types'
export type ReasoningConfigInputValue = {
type?: VarKindType
value?: unknown
[key: string]: unknown
} | null
export type ReasoningConfigInput = {
value: ReasoningConfigInputValue
auto?: 0 | 1
}
export type ReasoningConfigValue = Record<string, ReasoningConfigInput>
export const getVarKindType = (type: string) => {
if (type === FormTypeEnum.file || type === FormTypeEnum.files)
return VarKindType.variable
if ([FormTypeEnum.select, FormTypeEnum.checkbox, FormTypeEnum.textNumber, FormTypeEnum.array, FormTypeEnum.object].includes(type as FormTypeEnum))
return VarKindType.constant
if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput)
return VarKindType.mixed
return undefined
}
export const resolveTargetVarType = (type: string) => {
if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput)
return VarType.string
if (type === FormTypeEnum.textNumber)
return VarType.number
if (type === FormTypeEnum.files)
return VarType.arrayFile
if (type === FormTypeEnum.file)
return VarType.file
if (type === FormTypeEnum.checkbox)
return VarType.boolean
if (type === FormTypeEnum.object)
return VarType.object
if (type === FormTypeEnum.array)
return VarType.arrayObject
return VarType.string
}
export const createFilterVar = (type: string) => {
if (type === FormTypeEnum.textNumber)
return (varPayload: Var) => varPayload.type === VarType.number
if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput)
return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
if (type === FormTypeEnum.file || type === FormTypeEnum.files)
return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
if (type === FormTypeEnum.checkbox)
return (varPayload: Var) => varPayload.type === VarType.boolean
if (type === FormTypeEnum.object)
return (varPayload: Var) => varPayload.type === VarType.object
if (type === FormTypeEnum.array)
return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return undefined
}
export const getVisibleSelectOptions = (
options: NonNullable<ToolFormSchema['options']>,
value: ReasoningConfigValue,
language: string,
) => {
return options.filter((option) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value)
return true
}).map(option => ({
value: option.value,
name: option.label[language] || option.label.en_US,
}))
}
export const updateInputAutoState = (
value: ReasoningConfigValue,
variable: string,
enabled: boolean,
type: string,
) => {
return {
...value,
[variable]: {
value: enabled ? null : { type: getVarKindType(type), value: null },
auto: enabled ? 1 as const : 0 as const,
},
}
}
export const updateVariableTypeValue = (
value: ReasoningConfigValue,
variable: string,
newType: VarKindType,
defaultValue: unknown,
) => {
return produce(value, (draft) => {
draft[variable].value = {
type: newType,
value: newType === VarKindType.variable ? '' : defaultValue,
}
})
}
export const updateReasoningValue = (
value: ReasoningConfigValue,
variable: string,
type: string,
newValue: unknown,
) => {
return produce(value, (draft) => {
draft[variable].value = {
type: getVarKindType(type),
value: newValue,
}
})
}
export const mergeReasoningValue = (
value: ReasoningConfigValue,
variable: string,
newValue: Record<string, unknown>,
) => {
return produce(value, (draft) => {
const currentValue = draft[variable].value as Record<string, unknown> | undefined
draft[variable].value = {
...currentValue,
...newValue,
}
})
}
export const updateVariableSelectorValue = (
value: ReasoningConfigValue,
variable: string,
newValue: ValueSelector | string,
) => {
return produce(value, (draft) => {
draft[variable].value = {
type: VarKindType.variable,
value: newValue,
}
})
}
export const getFieldFlags = (type: string, varInput?: ReasoningConfigInputValue) => {
const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
const isNumber = type === FormTypeEnum.textNumber
const isObject = type === FormTypeEnum.object
const isArray = type === FormTypeEnum.array
const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files
const isBoolean = type === FormTypeEnum.checkbox
const isSelect = type === FormTypeEnum.select
const isAppSelector = type === FormTypeEnum.appSelector
const isModelSelector = type === FormTypeEnum.modelSelector
const isConstant = varInput?.type === VarKindType.constant || !varInput?.type
return {
isString,
isNumber,
isObject,
isArray,
isShowJSONEditor: isObject || isArray,
isFile,
isBoolean,
isSelect,
isAppSelector,
isModelSelector,
showTypeSwitch: isNumber || isObject || isArray,
isConstant,
showVariableSelector: isFile || varInput?.type === VarKindType.variable,
}
}
export const createPickerProps = ({
type,
value,
language,
schema,
}: {
type: string
value: ReasoningConfigValue
language: string
schema: ToolFormSchema
}) => {
return {
filterVar: createFilterVar(type),
schema: schema as Partial<CredentialFormSchema>,
targetVarType: resolveTargetVarType(type),
selectItems: schema.options ? getVisibleSelectOptions(schema.options, value, language) : [],
}
}
export const getFieldTitle = (labels: { [key: string]: string }, language: string) => {
return labels[language] || labels.en_US
}
export const createEmptyAppValue = () => ({
app_id: '',
inputs: {},
files: [],
})
export const createReasoningFormContext = ({
availableNodes,
nodeId,
nodeOutputVars,
}: {
availableNodes: Node[]
nodeId: string
nodeOutputVars: NodeOutPutVar[]
}) => ({
availableNodes,
nodeId,
nodeOutputVars,
})

View File

@ -1,19 +1,16 @@
import type { Node } from 'reactflow'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ReasoningConfigValue as ReasoningConfigValueShape } from './reasoning-config-form.helpers'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types'
import type {
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import {
RiArrowRightUpLine,
RiBracesLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { produce } from 'immer'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
@ -31,21 +28,21 @@ import VarReferencePicker from '@/app/components/workflow/nodes/_base/components
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import {
createPickerProps,
getFieldFlags,
getFieldTitle,
mergeReasoningValue,
resolveTargetVarType,
updateInputAutoState,
updateReasoningValue,
updateVariableSelectorValue,
updateVariableTypeValue,
} from './reasoning-config-form.helpers'
import SchemaModal from './schema-modal'
type ReasoningConfigInputValue = {
type?: VarKindType
value?: unknown
} | null
type ReasoningConfigInput = {
value: ReasoningConfigInputValue
auto?: 0 | 1
}
export type ReasoningConfigValue = Record<string, ReasoningConfigInput>
export type ReasoningConfigValue = ReasoningConfigValueShape
type Props = {
value: ReasoningConfigValue
@ -66,79 +63,42 @@ const ReasoningConfigForm: React.FC<Props> = ({
}) => {
const { t } = useTranslation()
const language = useLanguage()
const getVarKindType = (type: string) => {
if (type === FormTypeEnum.file || type === FormTypeEnum.files)
return VarKindType.variable
if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object)
return VarKindType.constant
if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput)
return VarKindType.mixed
}
const handleAutomatic = (key: string, val: boolean, type: string) => {
onChange({
...value,
[key]: {
value: val ? null : { type: getVarKindType(type), value: null },
auto: val ? 1 : 0,
},
})
onChange(updateInputAutoState(value, key, val, type))
}
const handleTypeChange = useCallback((variable: string, defaultValue: unknown) => {
return (newType: VarKindType) => {
const res = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = {
type: newType,
value: newType === VarKindType.variable ? '' : defaultValue,
}
})
onChange(res)
onChange(updateVariableTypeValue(value, variable, newType, defaultValue))
}
}, [onChange, value])
const handleValueChange = useCallback((variable: string, varType: string) => {
return (newValue: unknown) => {
const res = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = {
type: getVarKindType(varType),
value: newValue,
}
})
onChange(res)
onChange(updateReasoningValue(value, variable, varType, newValue))
}
}, [onChange, value])
const handleAppChange = useCallback((variable: string) => {
return (app: {
app_id: string
inputs: Record<string, unknown>
files?: unknown[]
}) => {
const newValue = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = app
})
onChange(newValue)
onChange(updateReasoningValue(value, variable, FormTypeEnum.appSelector, app))
}
}, [onChange, value])
const handleModelChange = useCallback((variable: string) => {
return (model: Record<string, unknown>) => {
const newValue = produce(value, (draft: ToolVarInputs) => {
const currentValue = draft[variable].value as Record<string, unknown> | undefined
draft[variable].value = {
...currentValue,
...model,
}
})
onChange(newValue)
onChange(mergeReasoningValue(value, variable, model))
}
}, [onChange, value])
const handleVariableSelectorChange = useCallback((variable: string) => {
return (newValue: ValueSelector | string) => {
const res = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = {
type: VarKindType.variable,
value: newValue,
}
})
onChange(res)
onChange(updateVariableSelectorValue(value, variable, newValue))
}
}, [onChange, value])
@ -165,6 +125,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
options,
} = schema
const auto = value[variable]?.auto
const fieldTitle = getFieldTitle(label, language)
const tooltipContent = (tooltip && (
<Tooltip
popupContent={(
@ -177,64 +138,36 @@ const ReasoningConfigForm: React.FC<Props> = ({
/>
))
const varInput = value[variable].value
const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
const isNumber = type === FormTypeEnum.textNumber
const isObject = type === FormTypeEnum.object
const isArray = type === FormTypeEnum.array
const isShowJSONEditor = isObject || isArray
const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files
const isBoolean = type === FormTypeEnum.checkbox
const isSelect = type === FormTypeEnum.select
const isAppSelector = type === FormTypeEnum.appSelector
const isModelSelector = type === FormTypeEnum.modelSelector
const showTypeSwitch = isNumber || isObject || isArray
const isConstant = varInput?.type === VarKindType.constant || !varInput?.type
const showVariableSelector = isFile || varInput?.type === VarKindType.variable
const targetVarType = () => {
if (isString)
return VarType.string
else if (isNumber)
return VarType.number
else if (type === FormTypeEnum.files)
return VarType.arrayFile
else if (type === FormTypeEnum.file)
return VarType.file
else if (isBoolean)
return VarType.boolean
else if (isObject)
return VarType.object
else if (isArray)
return VarType.arrayObject
else
return VarType.string
}
const getFilterVar = () => {
if (isNumber)
return (varPayload: Var) => varPayload.type === VarType.number
else if (isString)
return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
else if (isFile)
return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
else if (isBoolean)
return (varPayload: Var) => varPayload.type === VarType.boolean
else if (isObject)
return (varPayload: Var) => varPayload.type === VarType.object
else if (isArray)
return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return undefined
}
const {
isString,
isNumber,
isShowJSONEditor,
isBoolean,
isSelect,
isAppSelector,
isModelSelector,
showTypeSwitch,
isConstant,
showVariableSelector,
} = getFieldFlags(type, varInput)
const pickerProps = createPickerProps({
type,
value,
language,
schema,
})
return (
<div key={variable} className="space-y-0.5">
<div className="system-sm-semibold flex items-center justify-between py-2 text-text-secondary">
<div className="flex items-center">
<span className={cn('code-sm-semibold max-w-[140px] truncate text-text-secondary')} title={label[language] || label.en_US}>{label[language] || label.en_US}</span>
<span className={cn('code-sm-semibold max-w-[140px] truncate text-text-secondary')} title={fieldTitle}>{fieldTitle}</span>
{required && (
<span className="ml-1 text-red-500">*</span>
)}
{tooltipContent}
<span className="system-xs-regular mx-1 text-text-quaternary">·</span>
<span className="system-xs-regular text-text-tertiary">{targetVarType()}</span>
<span className="system-xs-regular text-text-tertiary">{resolveTargetVarType(type)}</span>
{isShowJSONEditor && (
<Tooltip
popupContent={(
@ -246,7 +179,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
>
<div
className="ml-0.5 cursor-pointer radius-xs p-px text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => showSchema(input_schema as SchemaRoot, label[language] || label.en_US)}
onClick={() => showSchema(input_schema as SchemaRoot, fieldTitle)}
>
<RiBracesLine className="size-3.5" />
</div>
@ -295,12 +228,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<SimpleSelect
wrapperClassName="h-8 grow"
defaultValue={varInput?.value as string | number | undefined}
items={options.filter((option) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value)
return true
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
items={pickerProps.selectItems}
onSelect={item => handleValueChange(variable, type)(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
@ -347,9 +275,9 @@ const ReasoningConfigForm: React.FC<Props> = ({
nodeId={nodeId}
value={(varInput?.value as string | ValueSelector) || []}
onChange={handleVariableSelectorChange(variable)}
filterVar={getFilterVar()}
schema={schema as Partial<CredentialFormSchema>}
valueTypePlaceHolder={targetVarType()}
filterVar={pickerProps.filterVar}
schema={pickerProps.schema}
valueTypePlaceHolder={pickerProps.targetVarType}
/>
)}
</div>

View File

@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { usePluginInstalledCheck, useToolSelectorState } from '../index'
describe('tool-selector hooks index', () => {
it('re-exports the tool selector hooks', () => {
expect(usePluginInstalledCheck).toBeTypeOf('function')
expect(useToolSelectorState).toBeTypeOf('function')
})
})

View File

@ -0,0 +1,76 @@
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { usePluginPageContext } from '../context'
import { PluginPageContextProvider } from '../context-provider'
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('../../hooks', () => ({
PLUGIN_PAGE_TABS_MAP: {
plugins: 'plugins',
marketplace: 'discover',
},
usePluginPageTabs: () => [
{ value: 'plugins', text: 'Plugins' },
{ value: 'discover', text: 'Discover' },
],
}))
const mockGlobalPublicStore = (enableMarketplace: boolean) => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = { systemFeatures: { enable_marketplace: enableMarketplace } }
return selector(state as Parameters<typeof selector>[0])
})
}
const Consumer = () => {
const currentPluginID = usePluginPageContext(v => v.currentPluginID)
const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID)
const options = usePluginPageContext(v => v.options)
return (
<div>
<span data-testid="current-plugin">{currentPluginID ?? 'none'}</span>
<span data-testid="options-count">{options.length}</span>
<button onClick={() => setCurrentPluginID('plugin-1')}>select plugin</button>
</div>
)
}
describe('PluginPageContextProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('filters out the marketplace tab when the feature is disabled', () => {
mockGlobalPublicStore(false)
renderWithNuqs(
<PluginPageContextProvider>
<Consumer />
</PluginPageContextProvider>,
)
expect(screen.getByTestId('options-count')).toHaveTextContent('1')
})
it('keeps the query-state tab and updates the current plugin id', () => {
mockGlobalPublicStore(true)
renderWithNuqs(
<PluginPageContextProvider>
<Consumer />
</PluginPageContextProvider>,
{ searchParams: '?tab=discover' },
)
fireEvent.click(screen.getByText('select plugin'))
expect(screen.getByTestId('current-plugin')).toHaveTextContent('plugin-1')
expect(screen.getByTestId('options-count')).toHaveTextContent('2')
})
})

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DebugInfo from '../debug-info'
const mockDebugKey = vi.hoisted(() => ({
data: null as null | { key: string, host: string, port: number },
isLoading: false,
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/service/use-plugins', () => ({
useDebugKey: () => mockDebugKey,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children }: { children: React.ReactNode }) => <button data-testid="debug-button">{children}</button>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
children,
disabled,
popupContent,
}: {
children: React.ReactNode
disabled?: boolean
popupContent: React.ReactNode
}) => (
<div>
{children}
{!disabled && <div data-testid="tooltip-content">{popupContent}</div>}
</div>
),
}))
vi.mock('../../base/key-value-item', () => ({
default: ({
label,
value,
maskedValue,
}: {
label: string
value: string
maskedValue?: string
}) => (
<div data-testid={`kv-${label}`}>
{label}
:
{maskedValue || value}
</div>
),
}))
describe('DebugInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDebugKey.data = null
mockDebugKey.isLoading = false
})
it('renders nothing while the debug key is loading', () => {
mockDebugKey.isLoading = true
const { container } = render(<DebugInfo />)
expect(container.innerHTML).toBe('')
})
it('renders debug metadata and masks the key when info is available', () => {
mockDebugKey.data = {
host: '127.0.0.1',
port: 5001,
key: '12345678abcdefghijklmnopqrst87654321',
}
render(<DebugInfo />)
expect(screen.getByTestId('debug-button')).toBeInTheDocument()
expect(screen.getByText('plugin.debugInfo.title')).toBeInTheDocument()
expect(screen.getByRole('link')).toHaveAttribute(
'href',
'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin',
)
expect(screen.getByTestId('kv-URL')).toHaveTextContent('URL:127.0.0.1:5001')
expect(screen.getByTestId('kv-Key')).toHaveTextContent('Key:12345678********87654321')
})
})

View File

@ -0,0 +1,156 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import InstallPluginDropdown from '../install-plugin-dropdown'
let portalOpen = false
const {
mockSystemFeatures,
} = vi.hoisted(() => ({
mockSystemFeatures: {
enable_marketplace: true,
plugin_installation_permission: {
restrict_to_marketplace_only: false,
},
},
}))
vi.mock('@/config', () => ({
SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS: '.difypkg,.zip',
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
selector({ systemFeatures: mockSystemFeatures }),
}))
vi.mock('@/app/components/base/icons/src/vender/solid/files', () => ({
FileZip: () => <span data-testid="file-zip-icon">file</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
Github: () => <span data-testid="github-icon">github</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({
MagicBox: () => <span data-testid="magic-box-icon">magic</span>,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children }: { children: React.ReactNode }) => <span data-testid="button-content">{children}</span>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const React = await import('react')
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children: React.ReactNode
}) => {
portalOpen = open
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="dropdown-trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({
children,
}: {
children: React.ReactNode
}) => portalOpen ? <div data-testid="dropdown-content">{children}</div> : null,
}
})
vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="github-modal">
<button data-testid="close-github-modal" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({
default: ({
file,
onClose,
}: {
file: File
onClose: () => void
}) => (
<div data-testid="local-modal">
<span>{file.name}</span>
<button data-testid="close-local-modal" onClick={onClose}>close</button>
</div>
),
}))
describe('InstallPluginDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
mockSystemFeatures.enable_marketplace = true
mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = false
})
it('shows all install methods when marketplace and custom installs are enabled', () => {
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
expect(screen.getByText('plugin.installFrom')).toBeInTheDocument()
expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument()
expect(screen.getByText('plugin.source.github')).toBeInTheDocument()
expect(screen.getByText('plugin.source.local')).toBeInTheDocument()
})
it('shows only marketplace when installation is restricted', () => {
mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = true
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument()
expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument()
})
it('switches to marketplace when the marketplace action is selected', () => {
const onSwitchToMarketplaceTab = vi.fn()
render(<InstallPluginDropdown onSwitchToMarketplaceTab={onSwitchToMarketplaceTab} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByText('plugin.source.marketplace'))
expect(onSwitchToMarketplaceTab).toHaveBeenCalledTimes(1)
})
it('opens the github installer when github is selected', () => {
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByText('plugin.source.github'))
expect(screen.getByTestId('github-modal')).toBeInTheDocument()
})
it('opens the local package installer when a file is selected', () => {
const { container } = render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.change(container.querySelector('input[type="file"]')!, {
target: {
files: [new File(['content'], 'plugin.difypkg')],
},
})
expect(screen.getByTestId('local-modal')).toBeInTheDocument()
expect(screen.getByText('plugin.difypkg')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,200 @@
import type { PluginDetail } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import PluginsPanel from '../plugins-panel'
const mockState = vi.hoisted(() => ({
filters: {
categories: [] as string[],
tags: [] as string[],
searchQuery: '',
},
currentPluginID: undefined as string | undefined,
}))
const mockSetFilters = vi.fn()
const mockSetCurrentPluginID = vi.fn()
const mockLoadNextPage = vi.fn()
const mockInvalidateInstalledPluginList = vi.fn()
const mockUseInstalledPluginList = vi.fn()
const mockPluginListWithLatestVersion = vi.fn<() => PluginDetail[]>(() => [])
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: (value: Record<string, string>, locale: string) => value[locale] || '',
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => mockUseInstalledPluginList(),
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
}))
vi.mock('../../hooks', () => ({
usePluginsWithLatestVersion: () => mockPluginListWithLatestVersion(),
}))
vi.mock('../context', () => ({
usePluginPageContext: (selector: (value: {
filters: typeof mockState.filters
setFilters: typeof mockSetFilters
currentPluginID: string | undefined
setCurrentPluginID: typeof mockSetCurrentPluginID
}) => unknown) => selector({
filters: mockState.filters,
setFilters: mockSetFilters,
currentPluginID: mockState.currentPluginID,
setCurrentPluginID: mockSetCurrentPluginID,
}),
}))
vi.mock('../filter-management', () => ({
default: ({ onFilterChange }: { onFilterChange: (filters: typeof mockState.filters) => void }) => (
<button
data-testid="filter-management"
onClick={() => onFilterChange({
categories: [],
tags: [],
searchQuery: 'beta',
})}
>
filter
</button>
),
}))
vi.mock('../empty', () => ({
default: () => <div data-testid="empty-state">empty</div>,
}))
vi.mock('../list', () => ({
default: ({ pluginList }: { pluginList: PluginDetail[] }) => <div data-testid="plugin-list">{pluginList.map(plugin => plugin.plugin_id).join(',')}</div>,
}))
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
default: ({ detail, onHide, onUpdate }: {
detail?: PluginDetail
onHide: () => void
onUpdate: () => void
}) => (
<div data-testid="plugin-detail-panel">
<span>{detail?.plugin_id ?? 'none'}</span>
<button onClick={onHide}>hide detail</button>
<button onClick={onUpdate}>refresh detail</button>
</div>
),
}))
const createPlugin = (pluginId: string, label: string, tags: string[] = []): PluginDetail => ({
id: pluginId,
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: label,
plugin_id: pluginId,
plugin_unique_identifier: `${pluginId}-uid`,
declaration: {
category: 'tool',
name: pluginId,
label: { en_US: label },
description: { en_US: `${label} description` },
tags,
} as PluginDetail['declaration'],
installation_id: `${pluginId}-install`,
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: `${pluginId}-uid`,
source: 'marketplace' as PluginDetail['source'],
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
}) as PluginDetail
describe('PluginsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockState.filters = { categories: [], tags: [], searchQuery: '' }
mockState.currentPluginID = undefined
mockUseInstalledPluginList.mockReturnValue({
data: { plugins: [] },
isLoading: false,
isFetching: false,
isLastPage: true,
loadNextPage: mockLoadNextPage,
})
mockPluginListWithLatestVersion.mockReturnValue([])
})
afterEach(() => {
vi.useRealTimers()
})
it('renders the loading state while the plugin list is pending', () => {
mockUseInstalledPluginList.mockReturnValue({
data: { plugins: [] },
isLoading: true,
isFetching: false,
isLastPage: true,
loadNextPage: mockLoadNextPage,
})
render(<PluginsPanel />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('filters the list and exposes the load-more action', () => {
mockState.filters.searchQuery = 'alpha'
mockPluginListWithLatestVersion.mockReturnValue([
createPlugin('alpha-tool', 'Alpha Tool', ['search']),
createPlugin('beta-tool', 'Beta Tool', ['rag']),
])
mockUseInstalledPluginList.mockReturnValue({
data: { plugins: [] },
isLoading: false,
isFetching: false,
isLastPage: false,
loadNextPage: mockLoadNextPage,
})
render(<PluginsPanel />)
expect(screen.getByTestId('plugin-list')).toHaveTextContent('alpha-tool')
expect(screen.queryByText('beta-tool')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('workflow.common.loadMore'))
fireEvent.click(screen.getByTestId('filter-management'))
vi.runAllTimers()
expect(mockLoadNextPage).toHaveBeenCalled()
expect(mockSetFilters).toHaveBeenCalledWith({
categories: [],
tags: [],
searchQuery: 'beta',
})
})
it('renders the empty state and keeps the current plugin detail in sync', () => {
mockState.currentPluginID = 'beta-tool'
mockState.filters.searchQuery = 'missing'
mockPluginListWithLatestVersion.mockReturnValue([
createPlugin('beta-tool', 'Beta Tool'),
])
render(<PluginsPanel />)
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('beta-tool')
fireEvent.click(screen.getByText('hide detail'))
fireEvent.click(screen.getByText('refresh detail'))
expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined)
expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,32 @@
import type { Category, Tag } from '../constant'
import { describe, expect, it } from 'vitest'
describe('filter-management constant types', () => {
it('accepts tag objects with binding counts', () => {
const tag: Tag = {
id: 'tag-1',
name: 'search',
type: 'plugin',
binding_count: 3,
}
expect(tag).toEqual({
id: 'tag-1',
name: 'search',
type: 'plugin',
binding_count: 3,
})
})
it('accepts supported category names', () => {
const category: Category = {
name: 'tool',
binding_count: 8,
}
expect(category).toEqual({
name: 'tool',
binding_count: 8,
})
})
})

View File

@ -0,0 +1,76 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagFilter from '../tag-filter'
let portalOpen = false
vi.mock('../../../hooks', () => ({
useTags: () => ({
tags: [
{ name: 'agent', label: 'Agent' },
{ name: 'rag', label: 'RAG' },
{ name: 'search', label: 'Search' },
],
getTagLabel: (name: string) => ({
agent: 'Agent',
rag: 'RAG',
search: 'Search',
}[name] ?? name),
}),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
children: React.ReactNode
open: boolean
}) => {
portalOpen = open
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({
children,
}: {
children: React.ReactNode
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : null,
}))
describe('TagFilter', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
})
it('renders selected tag labels and the overflow counter', () => {
render(<TagFilter value={['agent', 'rag', 'search']} onChange={vi.fn()} />)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('filters options by search text and toggles tag selection', () => {
const onChange = vi.fn()
render(<TagFilter value={['agent']} onChange={onChange} />)
fireEvent.click(screen.getByTestId('trigger'))
const portal = screen.getByTestId('portal-content')
fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } })
expect(within(portal).queryByText('Agent')).not.toBeInTheDocument()
expect(within(portal).getByText('RAG')).toBeInTheDocument()
fireEvent.click(within(portal).getByText('RAG'))
expect(onChange).toHaveBeenCalledWith(['agent', 'rag'])
})
})

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { defaultValue } from '../config'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../types'
describe('auto-update config', () => {
it('provides the expected default auto update value', () => {
expect(defaultValue).toEqual({
strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
exclude_plugins: [],
include_plugins: [],
})
})
})

View File

@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import NoDataPlaceholder from '../no-data-placeholder'
describe('NoDataPlaceholder', () => {
it('renders the no-found state by default', () => {
const { container } = render(<NoDataPlaceholder className="min-h-10" />)
expect(container.querySelector('svg')).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noFound')).toBeInTheDocument()
})
it('renders the no-installed state when noPlugins is true', () => {
const { container } = render(<NoDataPlaceholder className="min-h-10" noPlugins />)
expect(container.querySelector('svg')).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import NoPluginSelected from '../no-plugin-selected'
import { AUTO_UPDATE_MODE } from '../types'
describe('NoPluginSelected', () => {
it('renders partial mode placeholder', () => {
render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.partial} />)
expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument()
})
it('renders exclude mode placeholder', () => {
render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.exclude} />)
expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.exclude')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,82 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PluginsPicker from '../plugins-picker'
import { AUTO_UPDATE_MODE } from '../types'
const mockToolPicker = vi.fn()
vi.mock('@/app/components/base/button', () => ({
default: ({
children,
}: {
children: React.ReactNode
}) => <button>{children}</button>,
}))
vi.mock('../no-plugin-selected', () => ({
default: ({ updateMode }: { updateMode: AUTO_UPDATE_MODE }) => <div data-testid="no-plugin-selected">{updateMode}</div>,
}))
vi.mock('../plugins-selected', () => ({
default: ({ plugins }: { plugins: string[] }) => <div data-testid="plugins-selected">{plugins.join(',')}</div>,
}))
vi.mock('../tool-picker', () => ({
default: (props: Record<string, unknown>) => {
mockToolPicker(props)
return <div data-testid="tool-picker">tool-picker</div>
},
}))
describe('PluginsPicker', () => {
it('renders the empty state when no plugins are selected', () => {
render(
<PluginsPicker
updateMode={AUTO_UPDATE_MODE.partial}
value={[]}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('no-plugin-selected')).toHaveTextContent(AUTO_UPDATE_MODE.partial)
expect(screen.queryByTestId('plugins-selected')).not.toBeInTheDocument()
expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({
value: [],
isShow: false,
onShowChange: expect.any(Function),
}))
})
it('renders selected plugins summary and clears them', () => {
const onChange = vi.fn()
render(
<PluginsPicker
updateMode={AUTO_UPDATE_MODE.exclude}
value={['dify/plugin-1', 'dify/plugin-2']}
onChange={onChange}
/>,
)
expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":2}')).toBeInTheDocument()
expect(screen.getByTestId('plugins-selected')).toHaveTextContent('dify/plugin-1,dify/plugin-2')
fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll'))
expect(onChange).toHaveBeenCalledWith([])
})
it('passes the select button trigger into ToolPicker', () => {
render(
<PluginsPicker
updateMode={AUTO_UPDATE_MODE.partial}
value={[]}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('tool-picker')).toBeInTheDocument()
expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({
trigger: expect.anything(),
}))
})
})

View File

@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PluginsSelected from '../plugins-selected'
vi.mock('@/config', () => ({
MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src }: { src: string }) => <div data-testid="plugin-icon">{src}</div>,
}))
describe('PluginsSelected', () => {
it('renders all selected plugin icons when the count is below the limit', () => {
render(<PluginsSelected plugins={['dify/plugin-1', 'dify/plugin-2']} />)
expect(screen.getAllByTestId('plugin-icon')).toHaveLength(2)
expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument()
expect(screen.queryByText('+1')).not.toBeInTheDocument()
})
it('renders the overflow badge when more than fourteen plugins are selected', () => {
const plugins = Array.from({ length: 16 }, (_, index) => `dify/plugin-${index}`)
render(<PluginsSelected plugins={plugins} />)
expect(screen.getAllByTestId('plugin-icon')).toHaveLength(14)
expect(screen.getByText('+2')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,100 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StrategyPicker from '../strategy-picker'
import { AUTO_UPDATE_STRATEGY } from '../types'
let portalOpen = false
vi.mock('@/app/components/base/button', () => ({
default: ({
children,
}: {
children: React.ReactNode
}) => <span data-testid="picker-button">{children}</span>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const React = await import('react')
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children: React.ReactNode
}) => {
portalOpen = open
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: (event: { stopPropagation: () => void, nativeEvent: { stopImmediatePropagation: () => void } }) => void
}) => (
<button
data-testid="trigger"
onClick={() => onClick({
stopPropagation: vi.fn(),
nativeEvent: { stopImmediatePropagation: vi.fn() },
})}
>
{children}
</button>
),
PortalToFollowElemContent: ({
children,
}: {
children: React.ReactNode
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : null,
}
})
describe('StrategyPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
})
it('renders the selected strategy label in the trigger', () => {
render(
<StrategyPicker
value={AUTO_UPDATE_STRATEGY.fixOnly}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger')).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
})
it('opens the option list when the trigger is clicked', () => {
render(
<StrategyPicker
value={AUTO_UPDATE_STRATEGY.disabled}
onChange={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('trigger'))
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1)
expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument()
})
it('calls onChange when a new strategy is selected', () => {
const onChange = vi.fn()
render(
<StrategyPicker
value={AUTO_UPDATE_STRATEGY.disabled}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByTestId('trigger'))
fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name'))
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
})
})

View File

@ -0,0 +1,65 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ToolItem from '../tool-item'
vi.mock('@/config', () => ({
MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: (value: Record<string, string>, language: string) => value[language],
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src }: { src: string }) => <div data-testid="plugin-icon">{src}</div>,
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({
checked,
onCheck,
}: {
checked?: boolean
onCheck: () => void
}) => (
<button data-testid="checkbox" onClick={onCheck}>
{String(checked)}
</button>
),
}))
const payload = {
plugin_id: 'dify/plugin-1',
declaration: {
label: {
en_US: 'Plugin One',
zh_Hans: 'Plugin One',
},
author: 'Dify',
},
} as PluginDetail
describe('ToolItem', () => {
it('renders plugin metadata and marketplace icon', () => {
render(<ToolItem payload={payload} isChecked onCheckChange={vi.fn()} />)
expect(screen.getByText('Plugin One')).toBeInTheDocument()
expect(screen.getByText('Dify')).toBeInTheDocument()
expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument()
expect(screen.getByText('true')).toBeInTheDocument()
})
it('calls onCheckChange when checkbox is clicked', () => {
const onCheckChange = vi.fn()
render(<ToolItem payload={payload} onCheckChange={onCheckChange} />)
fireEvent.click(screen.getByTestId('checkbox'))
expect(onCheckChange).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,248 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '@/app/components/plugins/types'
import ToolPicker from '../tool-picker'
let portalOpen = false
const mockInstalledPluginList = vi.hoisted(() => ({
data: {
plugins: [] as PluginDetail[],
},
isLoading: false,
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => mockInstalledPluginList,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const React = await import('react')
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children: React.ReactNode
}) => {
portalOpen = open
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => portalOpen ? <div data-testid="portal-content" className={className}>{children}</div> : null,
}
})
vi.mock('@/app/components/plugins/marketplace/search-box', () => ({
default: ({
search,
tags,
onSearchChange,
onTagsChange,
placeholder,
}: {
search: string
tags: string[]
onSearchChange: (value: string) => void
onTagsChange: (value: string[]) => void
placeholder: string
}) => (
<div data-testid="search-box">
<div>{placeholder}</div>
<div data-testid="search-state">{search}</div>
<div data-testid="tags-state">{tags.join(',')}</div>
<button data-testid="set-query" onClick={() => onSearchChange('tool-rag')}>set-query</button>
<button data-testid="set-tags" onClick={() => onTagsChange(['rag'])}>set-tags</button>
</div>
),
}))
vi.mock('../no-data-placeholder', () => ({
default: ({
noPlugins,
}: {
noPlugins?: boolean
}) => <div data-testid="no-data">{String(noPlugins)}</div>,
}))
vi.mock('../tool-item', () => ({
default: ({
payload,
isChecked,
onCheckChange,
}: {
payload: PluginDetail
isChecked?: boolean
onCheckChange: () => void
}) => (
<div data-testid="tool-item">
<span>{payload.plugin_id}</span>
<span>{String(isChecked)}</span>
<button data-testid={`toggle-${payload.plugin_id}`} onClick={onCheckChange}>toggle</button>
</div>
),
}))
const createPlugin = (
pluginId: string,
source: PluginDetail['source'],
category: string,
tags: string[],
): PluginDetail => ({
plugin_id: pluginId,
source,
declaration: {
category,
tags,
},
} as PluginDetail)
describe('ToolPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
mockInstalledPluginList.data = {
plugins: [],
}
mockInstalledPluginList.isLoading = false
})
it('toggles popup visibility from the trigger', () => {
const onShowChange = vi.fn()
render(
<ToolPicker
trigger={<span>trigger</span>}
value={[]}
onChange={vi.fn()}
isShow={false}
onShowChange={onShowChange}
/>,
)
fireEvent.click(screen.getByTestId('trigger'))
expect(onShowChange).toHaveBeenCalledWith(true)
})
it('renders loading content while installed plugins are loading', () => {
mockInstalledPluginList.isLoading = true
render(
<ToolPicker
trigger={<span>trigger</span>}
value={[]}
onChange={vi.fn()}
isShow
onShowChange={vi.fn()}
/>,
)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('renders no-data placeholder when there are no matching marketplace plugins', () => {
render(
<ToolPicker
trigger={<span>trigger</span>}
value={[]}
onChange={vi.fn()}
isShow
onShowChange={vi.fn()}
/>,
)
expect(screen.getByTestId('no-data')).toHaveTextContent('true')
})
it('filters by plugin type, tags, and query', () => {
mockInstalledPluginList.data = {
plugins: [
createPlugin('tool-search', PluginSource.marketplace, 'tool', ['search']),
createPlugin('tool-rag', PluginSource.marketplace, 'tool', ['rag']),
createPlugin('model-agent', PluginSource.marketplace, 'model', ['agent']),
createPlugin('github-tool', PluginSource.github, 'tool', ['rag']),
],
}
render(
<ToolPicker
trigger={<span>trigger</span>}
value={[]}
onChange={vi.fn()}
isShow
onShowChange={vi.fn()}
/>,
)
expect(screen.getAllByTestId('tool-item')).toHaveLength(3)
expect(screen.queryByText('github-tool')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('plugin.category.models'))
expect(screen.getAllByTestId('tool-item')).toHaveLength(1)
expect(screen.getByText('model-agent')).toBeInTheDocument()
fireEvent.click(screen.getByText('plugin.category.tools'))
expect(screen.getAllByTestId('tool-item')).toHaveLength(2)
fireEvent.click(screen.getByTestId('set-tags'))
expect(screen.getAllByTestId('tool-item')).toHaveLength(1)
expect(screen.getByText('tool-rag')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('set-query'))
expect(screen.getAllByTestId('tool-item')).toHaveLength(1)
expect(screen.getByTestId('search-state')).toHaveTextContent('tool-rag')
})
it('adds and removes plugin ids from the selection', () => {
mockInstalledPluginList.data = {
plugins: [
createPlugin('tool-rag', PluginSource.marketplace, 'tool', ['rag']),
createPlugin('tool-search', PluginSource.marketplace, 'tool', ['search']),
],
}
const onChange = vi.fn()
const { rerender } = render(
<ToolPicker
trigger={<span>trigger</span>}
value={['tool-rag']}
onChange={onChange}
isShow
onShowChange={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('toggle-tool-search'))
expect(onChange).toHaveBeenCalledWith(['tool-rag', 'tool-search'])
rerender(
<ToolPicker
trigger={<span>trigger</span>}
value={['tool-rag']}
onChange={onChange}
isShow
onShowChange={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('toggle-tool-rag'))
expect(onChange).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,226 @@
import type { UpdateFromMarketPlacePayload } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
import UpdateFromMarketplace from '../from-market-place'
const {
mockStop,
mockCheck,
mockHandleRefetch,
mockInvalidateReferenceSettings,
mockRemoveAutoUpgrade,
mockUpdateFromMarketPlace,
mockToastError,
} = vi.hoisted(() => ({
mockStop: vi.fn(),
mockCheck: vi.fn(),
mockHandleRefetch: vi.fn(),
mockInvalidateReferenceSettings: vi.fn(),
mockRemoveAutoUpgrade: vi.fn(),
mockUpdateFromMarketPlace: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogCloseButton: () => <button>close dialog</button>,
}))
vi.mock('@/app/components/base/badge/index', () => ({
__esModule: true,
BadgeState: {
Warning: 'warning',
},
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({
children,
onClick,
disabled,
}: {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
}) => <button disabled={disabled} onClick={onClick}>{children}</button>,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('@/app/components/plugins/card', () => ({
default: ({ titleLeft, payload }: { titleLeft: React.ReactNode, payload: { label: Record<string, string> } }) => (
<div data-testid="plugin-card">
<div>{payload.label.en_US}</div>
<div>{titleLeft}</div>
</div>
),
}))
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
default: () => ({
check: mockCheck,
stop: mockStop,
}),
}))
vi.mock('@/app/components/plugins/install-plugin/utils', () => ({
pluginManifestToCardPluginProps: (payload: unknown) => payload,
}))
vi.mock('@/service/plugins', () => ({
updateFromMarketPlace: mockUpdateFromMarketPlace,
}))
vi.mock('@/service/use-plugins', () => ({
usePluginTaskList: () => ({
handleRefetch: mockHandleRefetch,
}),
useRemoveAutoUpgrade: () => ({
mutateAsync: mockRemoveAutoUpgrade,
}),
useInvalidateReferenceSettings: () => mockInvalidateReferenceSettings,
}))
vi.mock('../install-plugin/base/use-get-icon', () => ({
default: () => ({
getIconUrl: async (icon: string) => `https://cdn.example.com/${icon}`,
}),
}))
vi.mock('../downgrade-warning', () => ({
default: ({
onCancel,
onJustDowngrade,
onExcludeAndDowngrade,
}: {
onCancel: () => void
onJustDowngrade: () => void
onExcludeAndDowngrade: () => void
}) => (
<div data-testid="downgrade-warning">
<button onClick={onCancel}>cancel downgrade</button>
<button onClick={onJustDowngrade}>downgrade only</button>
<button onClick={onExcludeAndDowngrade}>exclude and downgrade</button>
</div>
),
}))
const createPayload = (overrides: Partial<UpdateFromMarketPlacePayload> = {}): UpdateFromMarketPlacePayload => ({
category: PluginCategoryEnum.tool,
originalPackageInfo: {
id: 'plugin@1.0.0',
payload: {
version: '1.0.0',
icon: 'plugin.png',
label: { en_US: 'Plugin Label' },
} as UpdateFromMarketPlacePayload['originalPackageInfo']['payload'],
},
targetPackageInfo: {
id: 'plugin@2.0.0',
version: '2.0.0',
},
...overrides,
})
describe('UpdateFromMarketplace', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheck.mockResolvedValue({ status: TaskStatus.success })
mockUpdateFromMarketPlace.mockResolvedValue({
all_installed: true,
task_id: 'task-1',
})
})
it('renders the upgrade modal content and current version transition', async () => {
render(
<UpdateFromMarketplace
payload={createPayload()}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument()
expect(screen.getByText('plugin.upgrade.description')).toBeInTheDocument()
expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByTestId('plugin-card')).toHaveTextContent('Plugin Label')
})
})
it('submits the marketplace upgrade and calls onSave when installation is immediate', async () => {
const onSave = vi.fn()
render(
<UpdateFromMarketplace
payload={createPayload()}
onSave={onSave}
onCancel={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('plugin.upgrade.upgrade'))
await waitFor(() => {
expect(mockUpdateFromMarketPlace).toHaveBeenCalledWith({
original_plugin_unique_identifier: 'plugin@1.0.0',
new_plugin_unique_identifier: 'plugin@2.0.0',
})
expect(onSave).toHaveBeenCalled()
})
})
it('surfaces failed upgrade messages from the response task payload', async () => {
mockUpdateFromMarketPlace.mockResolvedValue({
task: {
status: TaskStatus.failed,
plugins: [{
plugin_unique_identifier: 'plugin@2.0.0',
message: 'upgrade failed',
}],
},
})
render(
<UpdateFromMarketplace
payload={createPayload()}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('plugin.upgrade.upgrade'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('upgrade failed')
})
})
it('removes auto-upgrade before downgrading when the warning modal is shown', async () => {
render(
<UpdateFromMarketplace
payload={createPayload()}
pluginId="plugin-1"
onSave={vi.fn()}
onCancel={vi.fn()}
isShowDowngradeWarningModal
/>,
)
fireEvent.click(screen.getByText('exclude and downgrade'))
await waitFor(() => {
expect(mockRemoveAutoUpgrade).toHaveBeenCalledWith({ plugin_id: 'plugin-1' })
expect(mockInvalidateReferenceSettings).toHaveBeenCalled()
expect(mockUpdateFromMarketPlace).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,107 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PluginVersionPicker from '../plugin-version-picker'
type VersionItem = {
version: string
unique_identifier: string
created_at: string
}
const mockVersionList = vi.hoisted(() => ({
data: {
versions: [] as VersionItem[],
},
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatDate: (value: string, format: string) => `${value}:${format}`,
}),
}))
vi.mock('@/service/use-plugins', () => ({
useVersionListOfPlugin: () => ({
data: mockVersionList,
}),
}))
describe('PluginVersionPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
mockVersionList.data.versions = [
{
version: '2.0.0',
unique_identifier: 'uid-current',
created_at: '2024-01-02',
},
{
version: '1.0.0',
unique_identifier: 'uid-old',
created_at: '2023-12-01',
},
]
})
it('renders version options and highlights the current version', () => {
render(
<PluginVersionPicker
isShow
onShowChange={vi.fn()}
pluginID="plugin-1"
currentVersion="2.0.0"
trigger={<span>trigger</span>}
onSelect={vi.fn()}
/>,
)
expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument()
expect(screen.getByText('2.0.0')).toBeInTheDocument()
expect(screen.getByText('2024-01-02:appLog.dateTimeFormat')).toBeInTheDocument()
expect(screen.getByText('CURRENT')).toBeInTheDocument()
})
it('calls onSelect with downgrade metadata and closes the picker', () => {
const onSelect = vi.fn()
const onShowChange = vi.fn()
render(
<PluginVersionPicker
isShow
onShowChange={onShowChange}
pluginID="plugin-1"
currentVersion="2.0.0"
trigger={<span>trigger</span>}
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByText('1.0.0'))
expect(onSelect).toHaveBeenCalledWith({
version: '1.0.0',
unique_identifier: 'uid-old',
isDowngrade: true,
})
expect(onShowChange).toHaveBeenCalledWith(false)
})
it('does not call onSelect when the current version is clicked', () => {
const onSelect = vi.fn()
render(
<PluginVersionPicker
isShow
onShowChange={vi.fn()}
pluginID="plugin-1"
currentVersion="2.0.0"
trigger={<span>trigger</span>}
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByText('2.0.0'))
expect(onSelect).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,141 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import RagPipelineChildren from '../rag-pipeline-children'
let mockShowImportDSLModal = false
let mockSubscription: ((value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null
const {
mockSetShowImportDSLModal,
mockHandlePaneContextmenuCancel,
mockExportCheck,
mockHandleExportDSL,
mockUseRagPipelineSearch,
} = vi.hoisted(() => ({
mockSetShowImportDSLModal: vi.fn((value: boolean) => {
mockShowImportDSLModal = value
}),
mockHandlePaneContextmenuCancel: vi.fn(),
mockExportCheck: vi.fn(),
mockHandleExportDSL: vi.fn(),
mockUseRagPipelineSearch: vi.fn(),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: (callback: (value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => {
mockSubscription = callback
},
},
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
showImportDSLModal: boolean
setShowImportDSLModal: typeof mockSetShowImportDSLModal
}) => unknown) => selector({
showImportDSLModal: mockShowImportDSLModal,
setShowImportDSLModal: mockSetShowImportDSLModal,
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useDSL: () => ({
exportCheck: mockExportCheck,
handleExportDSL: mockHandleExportDSL,
}),
usePanelInteractions: () => ({
handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
}),
}))
vi.mock('../../hooks/use-rag-pipeline-search', () => ({
useRagPipelineSearch: mockUseRagPipelineSearch,
}))
vi.mock('../../../workflow/plugin-dependency', () => ({
default: () => <div data-testid="plugin-dependency" />,
}))
vi.mock('../panel', () => ({
default: () => <div data-testid="rag-panel" />,
}))
vi.mock('../publish-toast', () => ({
default: () => <div data-testid="publish-toast" />,
}))
vi.mock('../rag-pipeline-header', () => ({
default: () => <div data-testid="rag-header" />,
}))
vi.mock('../update-dsl-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="update-dsl-modal">
<button onClick={onCancel}>close import</button>
</div>
),
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({
envList,
onConfirm,
onClose,
}: {
envList: EnvironmentVariable[]
onConfirm: () => void
onClose: () => void
}) => (
<div data-testid="dsl-export-modal">
<div>{envList.map(env => env.name).join(',')}</div>
<button onClick={onConfirm}>confirm export</button>
<button onClick={onClose}>close export</button>
</div>
),
}))
describe('RagPipelineChildren', () => {
beforeEach(() => {
vi.clearAllMocks()
mockShowImportDSLModal = false
mockSubscription = null
})
it('should render the main pipeline children and the import modal when enabled', () => {
mockShowImportDSLModal = true
render(<RagPipelineChildren />)
fireEvent.click(screen.getByText('close import'))
expect(mockUseRagPipelineSearch).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument()
expect(screen.getByTestId('rag-header')).toBeInTheDocument()
expect(screen.getByTestId('rag-panel')).toBeInTheDocument()
expect(screen.getByTestId('publish-toast')).toBeInTheDocument()
expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument()
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false)
})
it('should show the DSL export confirmation modal after receiving the export event', () => {
render(<RagPipelineChildren />)
act(() => {
mockSubscription?.({
type: DSL_EXPORT_CHECK,
payload: {
data: [{ name: 'API_KEY' } as EnvironmentVariable],
},
})
})
fireEvent.click(screen.getByText('confirm export'))
expect(screen.getByTestId('dsl-export-modal')).toHaveTextContent('API_KEY')
expect(mockHandleExportDSL).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import PipelineScreenShot from '../screenshot'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: 'dark',
}),
}))
vi.mock('@/utils/var', () => ({
basePath: '/console',
}))
describe('PipelineScreenShot', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should build themed screenshot sources', () => {
const { container } = render(<PipelineScreenShot />)
const sources = container.querySelectorAll('source')
expect(sources).toHaveLength(3)
expect(sources[0]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline.png')
expect(sources[1]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@2x.png')
expect(sources[2]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@3x.png')
expect(screen.getByAltText('Pipeline Screenshot')).toHaveAttribute('src', '/console/screenshots/dark/Pipeline.png')
})
})

View File

@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react'
import QAItem from '../q-a-item'
import { QAItemType } from '../types'
describe('QAItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the question prefix', () => {
render(<QAItem type={QAItemType.Question} text="What is Dify?" />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('What is Dify?')).toBeInTheDocument()
})
it('should render the answer prefix', () => {
render(<QAItem type={QAItemType.Answer} text="An LLM app platform." />)
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('An LLM app platform.')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,97 @@
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { VAR_ITEM_TEMPLATE_IN_PIPELINE } from '@/config'
import { PipelineInputVarType } from '@/models/pipeline'
import { TransferMethod } from '@/types/app'
import { convertFormDataToINputField, convertToInputFieldFormData } from '../utils'
describe('input-field editor utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should convert pipeline input vars into form data', () => {
const result = convertToInputFieldFormData({
type: PipelineInputVarType.multiFiles,
label: 'Upload files',
variable: 'documents',
max_length: 5,
default_value: 'initial-value',
required: false,
tooltips: 'Tooltip text',
options: ['a', 'b'],
placeholder: 'Select files',
unit: 'MB',
allowed_file_upload_methods: [TransferMethod.local_file],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: ['pdf'],
})
expect(result).toEqual({
type: PipelineInputVarType.multiFiles,
label: 'Upload files',
variable: 'documents',
maxLength: 5,
default: 'initial-value',
required: false,
tooltips: 'Tooltip text',
options: ['a', 'b'],
placeholder: 'Select files',
unit: 'MB',
allowedFileUploadMethods: [TransferMethod.local_file],
allowedTypesAndExtensions: {
allowedFileTypes: [SupportUploadFileTypes.document],
allowedFileExtensions: ['pdf'],
},
})
})
it('should fall back to the default input variable template', () => {
const result = convertToInputFieldFormData()
expect(result).toEqual({
type: VAR_ITEM_TEMPLATE_IN_PIPELINE.type,
label: VAR_ITEM_TEMPLATE_IN_PIPELINE.label,
variable: VAR_ITEM_TEMPLATE_IN_PIPELINE.variable,
maxLength: undefined,
required: VAR_ITEM_TEMPLATE_IN_PIPELINE.required,
options: VAR_ITEM_TEMPLATE_IN_PIPELINE.options,
allowedTypesAndExtensions: {},
})
})
it('should convert form data back into pipeline input variables', () => {
const result = convertFormDataToINputField({
type: PipelineInputVarType.select,
label: 'Category',
variable: 'category',
maxLength: 10,
default: 'books',
required: true,
tooltips: 'Pick one',
options: ['books', 'music'],
placeholder: 'Choose',
unit: '',
allowedFileUploadMethods: [TransferMethod.local_file],
allowedTypesAndExtensions: {
allowedFileTypes: [SupportUploadFileTypes.document],
allowedFileExtensions: ['txt'],
},
})
expect(result).toEqual({
type: PipelineInputVarType.select,
label: 'Category',
variable: 'category',
max_length: 10,
default_value: 'books',
required: true,
tooltips: 'Pick one',
options: ['books', 'music'],
placeholder: 'Choose',
unit: '',
allowed_file_upload_methods: [TransferMethod.local_file],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: ['txt'],
})
})
})

View File

@ -0,0 +1,73 @@
import type { InputFieldFormProps } from '../types'
import { render, screen } from '@testing-library/react'
import { useAppForm } from '@/app/components/base/form'
import HiddenFields from '../hidden-fields'
import { useHiddenConfigurations } from '../hooks'
const { mockInputField } = vi.hoisted(() => ({
mockInputField: vi.fn(({ config }: { config: { variable: string } }) => {
return function FieldComponent() {
return <div data-testid="input-field">{config.variable}</div>
}
}),
}))
vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({
default: mockInputField,
}))
vi.mock('../hooks', () => ({
useHiddenConfigurations: vi.fn(),
}))
describe('HiddenFields', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should build fields from the hidden configuration list', () => {
vi.mocked(useHiddenConfigurations).mockReturnValue([
{ variable: 'default' },
{ variable: 'tooltips' },
] as ReturnType<typeof useHiddenConfigurations>)
const HiddenFieldsHarness = () => {
const initialData: InputFieldFormProps['initialData'] = {
variable: 'field_1',
options: ['option-a', 'option-b'],
}
const form = useAppForm({
defaultValues: initialData,
onSubmit: () => {},
})
const HiddenFieldsComp = HiddenFields({ initialData })
return <HiddenFieldsComp form={form} />
}
render(<HiddenFieldsHarness />)
expect(useHiddenConfigurations).toHaveBeenCalledWith({
options: ['option-a', 'option-b'],
})
expect(mockInputField).toHaveBeenCalledTimes(2)
expect(screen.getAllByTestId('input-field')).toHaveLength(2)
expect(screen.getByText('default')).toBeInTheDocument()
expect(screen.getByText('tooltips')).toBeInTheDocument()
})
it('should render nothing when there are no hidden configurations', () => {
vi.mocked(useHiddenConfigurations).mockReturnValue([])
const HiddenFieldsHarness = () => {
const initialData: InputFieldFormProps['initialData'] = { options: [] }
const form = useAppForm({
defaultValues: initialData,
onSubmit: () => {},
})
const HiddenFieldsComp = HiddenFields({ initialData })
return <HiddenFieldsComp form={form} />
}
const { container } = render(<HiddenFieldsHarness />)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -0,0 +1,85 @@
import type { ComponentType } from 'react'
import { render, screen } from '@testing-library/react'
import { useConfigurations } from '../hooks'
import InitialFields from '../initial-fields'
type MockForm = {
store: object
getFieldValue: (fieldName: string) => unknown
setFieldValue: (fieldName: string, value: unknown) => void
}
const {
mockForm,
mockInputField,
} = vi.hoisted(() => ({
mockForm: {
store: {},
getFieldValue: vi.fn(),
setFieldValue: vi.fn(),
} as MockForm,
mockInputField: vi.fn(({ config }: { config: { variable: string } }) => {
return function FieldComponent() {
return <div data-testid="input-field">{config.variable}</div>
}
}),
}))
vi.mock('@/app/components/base/form', () => ({
withForm: ({ render }: {
render: (props: { form: MockForm }) => React.ReactNode
}) => ({ form }: { form?: MockForm }) => render({ form: form ?? mockForm }),
}))
vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({
default: mockInputField,
}))
vi.mock('../hooks', () => ({
useConfigurations: vi.fn(),
}))
describe('InitialFields', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should build initial fields with the form accessors and supportFile flag', () => {
vi.mocked(useConfigurations).mockReturnValue([
{ variable: 'type' },
{ variable: 'label' },
] as ReturnType<typeof useConfigurations>)
const InitialFieldsComp = InitialFields({
initialData: { variable: 'field_1' },
supportFile: true,
}) as unknown as ComponentType
render(<InitialFieldsComp />)
expect(useConfigurations).toHaveBeenCalledWith(expect.objectContaining({
supportFile: true,
getFieldValue: expect.any(Function),
setFieldValue: expect.any(Function),
}))
expect(screen.getAllByTestId('input-field')).toHaveLength(2)
expect(screen.getByText('type')).toBeInTheDocument()
expect(screen.getByText('label')).toBeInTheDocument()
})
it('should delegate field accessors to the underlying form instance', () => {
vi.mocked(useConfigurations).mockReturnValue([] as ReturnType<typeof useConfigurations>)
mockForm.getFieldValue = vi.fn(() => 'label-value')
mockForm.setFieldValue = vi.fn()
const InitialFieldsComp = InitialFields({ supportFile: false }) as unknown as ComponentType
render(<InitialFieldsComp />)
const call = vi.mocked(useConfigurations).mock.calls[0]?.[0]
const value = call?.getFieldValue('label')
call?.setFieldValue('label', 'next-value')
expect(value).toBe('label-value')
expect(mockForm.getFieldValue).toHaveBeenCalledWith('label')
expect(mockForm.setFieldValue).toHaveBeenCalledWith('label', 'next-value')
})
})

View File

@ -0,0 +1,62 @@
import type { InputFieldFormProps } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { useAppForm } from '@/app/components/base/form'
import { PipelineInputVarType } from '@/models/pipeline'
import { useHiddenFieldNames } from '../hooks'
import ShowAllSettings from '../show-all-settings'
vi.mock('../hooks', () => ({
useHiddenFieldNames: vi.fn(),
}))
describe('ShowAllSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useHiddenFieldNames).mockReturnValue('default value, placeholder')
})
it('should render the summary and hidden field names', () => {
const ShowAllSettingsHarness = () => {
const initialData: InputFieldFormProps['initialData'] = {
type: PipelineInputVarType.textInput,
}
const form = useAppForm({
defaultValues: initialData,
onSubmit: () => {},
})
const ShowAllSettingsComp = ShowAllSettings({
initialData,
handleShowAllSettings: vi.fn(),
})
return <ShowAllSettingsComp form={form} />
}
render(<ShowAllSettingsHarness />)
expect(useHiddenFieldNames).toHaveBeenCalledWith(PipelineInputVarType.textInput)
expect(screen.getByText('appDebug.variableConfig.showAllSettings')).toBeInTheDocument()
expect(screen.getByText('default value, placeholder')).toBeInTheDocument()
})
it('should call the click handler when the row is pressed', () => {
const handleShowAllSettings = vi.fn()
const ShowAllSettingsHarness = () => {
const initialData: InputFieldFormProps['initialData'] = {
type: PipelineInputVarType.textInput,
}
const form = useAppForm({
defaultValues: initialData,
onSubmit: () => {},
})
const ShowAllSettingsComp = ShowAllSettings({
initialData,
handleShowAllSettings,
})
return <ShowAllSettingsComp form={form} />
}
render(<ShowAllSettingsHarness />)
fireEvent.click(screen.getByText('appDebug.variableConfig.showAllSettings'))
expect(handleShowAllSettings).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,83 @@
import type { InputVar } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import FieldItem from '../field-item'
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
type: PipelineInputVarType.textInput,
label: 'Field Label',
variable: 'field_name',
max_length: 48,
default_value: '',
required: true,
tooltips: '',
options: [],
placeholder: '',
unit: '',
allowed_file_upload_methods: [],
allowed_file_types: [],
allowed_file_extensions: [],
...overrides,
})
describe('FieldItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the variable, label, and required badge', () => {
render(
<FieldItem
payload={createInputVar()}
index={0}
onClickEdit={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getByText('field_name')).toBeInTheDocument()
expect(screen.getByText('Field Label')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.start.required')).toBeInTheDocument()
})
it('should show edit and delete controls on hover and trigger both callbacks', () => {
const onClickEdit = vi.fn()
const onRemove = vi.fn()
const { container } = render(
<FieldItem
payload={createInputVar({ variable: 'custom_field' })}
index={2}
onClickEdit={onClickEdit}
onRemove={onRemove}
/>,
)
fireEvent.mouseEnter(container.firstChild!)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
fireEvent.click(buttons[1])
expect(onClickEdit).toHaveBeenCalledWith('custom_field')
expect(onRemove).toHaveBeenCalledWith(2)
})
it('should keep the row readonly when readonly is enabled', () => {
const onClickEdit = vi.fn()
const onRemove = vi.fn()
const { container } = render(
<FieldItem
readonly
payload={createInputVar()}
index={0}
onClickEdit={onClickEdit}
onRemove={onRemove}
/>,
)
fireEvent.mouseEnter(container.firstChild!)
expect(screen.queryAllByRole('button')).toHaveLength(0)
expect(onClickEdit).not.toHaveBeenCalled()
expect(onRemove).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,60 @@
import type { InputVar } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import FieldListContainer from '../field-list-container'
const createInputVar = (variable: string): InputVar => ({
type: PipelineInputVarType.textInput,
label: variable,
variable,
max_length: 48,
default_value: '',
required: true,
tooltips: '',
options: [],
placeholder: '',
unit: '',
allowed_file_upload_methods: [],
allowed_file_types: [],
allowed_file_extensions: [],
})
describe('FieldListContainer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the field items inside the sortable container', () => {
const onListSortChange = vi.fn()
const { container } = render(
<FieldListContainer
inputFields={[createInputVar('field_1'), createInputVar('field_2')]}
onListSortChange={onListSortChange}
onRemoveField={vi.fn()}
onEditField={vi.fn()}
/>,
)
expect(screen.getAllByText('field_1').length).toBeGreaterThan(0)
expect(screen.getAllByText('field_2').length).toBeGreaterThan(0)
expect(container.querySelector('.handle')).toBeInTheDocument()
expect(onListSortChange).not.toHaveBeenCalled()
})
it('should honor readonly mode for the rendered field rows', () => {
const { container } = render(
<FieldListContainer
readonly
inputFields={[createInputVar('field_1'), createInputVar('field_2')]}
onListSortChange={vi.fn()}
onRemoveField={vi.fn()}
onEditField={vi.fn()}
/>,
)
const firstRow = container.querySelector('.handle')
fireEvent.mouseEnter(firstRow!)
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
})

View File

@ -0,0 +1,24 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { render, screen } from '@testing-library/react'
import Datasource from '../datasource'
vi.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: () => 'tool-icon',
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: ({ toolIcon }: { toolIcon: string }) => <div data-testid="block-icon">{toolIcon}</div>,
}))
describe('Datasource', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the datasource title and icon', () => {
render(<Datasource nodeData={{ title: 'Knowledge Base' } as DataSourceNodeType} />)
expect(screen.getByTestId('block-icon')).toHaveTextContent('tool-icon')
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react'
import GlobalInputs from '../global-inputs'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
popupContent,
}: {
popupContent: React.ReactNode
}) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('GlobalInputs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the title and tooltip copy', () => {
render(<GlobalInputs />)
expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument()
expect(screen.getByTestId('tooltip')).toHaveTextContent('datasetPipeline.inputFieldPanel.globalInputs.tooltip')
})
})

View File

@ -0,0 +1,73 @@
import type { Datasource } from '../../../test-run/types'
import { fireEvent, render, screen } from '@testing-library/react'
import DataSource from '../data-source'
const {
mockOnSelect,
mockUseDraftPipelinePreProcessingParams,
} = vi.hoisted(() => ({
mockOnSelect: vi.fn(),
mockUseDraftPipelinePreProcessingParams: vi.fn(() => ({
data: {
variables: [{ variable: 'source' }],
},
})),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }),
}))
vi.mock('@/service/use-pipeline', () => ({
useDraftPipelinePreProcessingParams: mockUseDraftPipelinePreProcessingParams,
}))
vi.mock('../../../test-run/preparation/data-source-options', () => ({
default: ({
onSelect,
dataSourceNodeId,
}: {
onSelect: (data: Datasource) => void
dataSourceNodeId: string
}) => (
<div data-testid="data-source-options" data-node-id={dataSourceNodeId}>
<button
onClick={() => onSelect({ nodeId: 'source-node' } as Datasource)}
>
select datasource
</button>
</div>
),
}))
vi.mock('../form', () => ({
default: ({ variables }: { variables: Array<{ variable: string }> }) => (
<div data-testid="preview-form">{variables.map(item => item.variable).join(',')}</div>
),
}))
describe('DataSource preview', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the datasource selection step and forward selected values', () => {
render(
<DataSource
onSelect={mockOnSelect}
dataSourceNodeId="node-1"
/>,
)
fireEvent.click(screen.getByText('select datasource'))
expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle')).toBeInTheDocument()
expect(screen.getByTestId('data-source-options')).toHaveAttribute('data-node-id', 'node-1')
expect(screen.getByTestId('preview-form')).toHaveTextContent('source')
expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalledWith({
pipeline_id: 'pipeline-1',
node_id: 'node-1',
}, true)
expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'source-node' })
})
})

View File

@ -0,0 +1,64 @@
import type { RAGPipelineVariables } from '@/models/pipeline'
import { render, screen } from '@testing-library/react'
import Form from '../form'
type MockForm = {
id: string
}
const {
mockForm,
mockBaseField,
mockUseInitialData,
mockUseConfigurations,
} = vi.hoisted(() => ({
mockForm: {
id: 'form-1',
} as MockForm,
mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => {
return function FieldComponent() {
return <div data-testid="base-field">{config.variable}</div>
}
}),
mockUseInitialData: vi.fn(() => ({ source: 'node-1' })),
mockUseConfigurations: vi.fn(() => [{ variable: 'source' }, { variable: 'chunkSize' }]),
}))
vi.mock('@/app/components/base/form', () => ({
useAppForm: () => mockForm,
}))
vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({
default: mockBaseField,
}))
vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({
useInitialData: mockUseInitialData,
useConfigurations: mockUseConfigurations,
}))
describe('Preview form', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should build fields from the pipeline variable configuration', () => {
render(<Form variables={[{ variable: 'source' }] as unknown as RAGPipelineVariables} />)
expect(mockUseInitialData).toHaveBeenCalled()
expect(mockUseConfigurations).toHaveBeenCalled()
expect(screen.getAllByTestId('base-field')).toHaveLength(2)
expect(screen.getByText('source')).toBeInTheDocument()
expect(screen.getByText('chunkSize')).toBeInTheDocument()
})
it('should prevent the native form submission', () => {
const { container } = render(<Form variables={[] as unknown as RAGPipelineVariables} />)
const form = container.querySelector('form')!
const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
form.dispatchEvent(submitEvent)
expect(submitEvent.defaultPrevented).toBe(true)
})
})

View File

@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react'
import ProcessDocuments from '../process-documents'
const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({
data: {
variables: [{ variable: 'chunkSize' }],
},
})))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }),
}))
vi.mock('@/service/use-pipeline', () => ({
useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams,
}))
vi.mock('../form', () => ({
default: ({ variables }: { variables: Array<{ variable: string }> }) => (
<div data-testid="preview-form">{variables.map(item => item.variable).join(',')}</div>
),
}))
describe('ProcessDocuments preview', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the processing step and its variables', () => {
render(<ProcessDocuments dataSourceNodeId="node-2" />)
expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle')).toBeInTheDocument()
expect(screen.getByTestId('preview-form')).toHaveTextContent('chunkSize')
expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({
pipeline_id: 'pipeline-1',
node_id: 'node-2',
}, true)
})
})

View File

@ -0,0 +1,60 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Header from '../header'
const {
mockSetIsPreparingDataSource,
mockHandleCancelDebugAndPreviewPanel,
mockWorkflowStore,
} = vi.hoisted(() => ({
mockSetIsPreparingDataSource: vi.fn(),
mockHandleCancelDebugAndPreviewPanel: vi.fn(),
mockWorkflowStore: {
getState: vi.fn(() => ({
isPreparingDataSource: true,
setIsPreparingDataSource: vi.fn(),
})),
},
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}))
describe('TestRun header', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.getState.mockReturnValue({
isPreparingDataSource: true,
setIsPreparingDataSource: mockSetIsPreparingDataSource,
})
})
it('should render the title and reset preparing state on close', () => {
render(<Header />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('datasetPipeline.testRun.title')).toBeInTheDocument()
expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
it('should only cancel the panel when the datasource preparation flag is false', () => {
mockWorkflowStore.getState.mockReturnValue({
isPreparingDataSource: false,
setIsPreparingDataSource: mockSetIsPreparingDataSource,
})
render(<Header />)
fireEvent.click(screen.getByRole('button'))
expect(mockSetIsPreparingDataSource).not.toHaveBeenCalled()
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,14 @@
import { render, screen } from '@testing-library/react'
import FooterTips from '../footer-tips'
describe('FooterTips', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the localized footer copy', () => {
render(<FooterTips />)
expect(screen.getByText('datasetPipeline.testRun.tooltip')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import StepIndicator from '../step-indicator'
describe('StepIndicator', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all step labels and highlight the current step', () => {
const { container } = render(
<StepIndicator
currentStep={2}
steps={[
{ label: 'Select source', value: 'source' },
{ label: 'Process docs', value: 'process' },
{ label: 'Run test', value: 'run' },
]}
/>,
)
expect(screen.getByText('Select source')).toBeInTheDocument()
expect(screen.getByText('Process docs')).toBeInTheDocument()
expect(screen.getByText('Run test')).toBeInTheDocument()
expect(container.querySelector('.bg-state-accent-solid')).toBeInTheDocument()
expect(screen.getByText('Process docs').parentElement).toHaveClass('text-state-accent-solid')
})
it('should keep inactive steps in the tertiary state', () => {
render(
<StepIndicator
currentStep={1}
steps={[
{ label: 'Select source', value: 'source' },
{ label: 'Process docs', value: 'process' },
]}
/>,
)
expect(screen.getByText('Process docs').parentElement).toHaveClass('text-text-tertiary')
})
})

View File

@ -0,0 +1,49 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { fireEvent, render, screen } from '@testing-library/react'
import OptionCard from '../option-card'
vi.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: () => 'source-icon',
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: ({ toolIcon }: { toolIcon: string }) => <div data-testid="block-icon">{toolIcon}</div>,
}))
describe('OptionCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the datasource label and icon', () => {
render(
<OptionCard
label="Website Crawl"
value="website"
selected={false}
nodeData={{ title: 'Website Crawl' } as DataSourceNodeType}
/>,
)
expect(screen.getByTestId('block-icon')).toHaveTextContent('source-icon')
expect(screen.getByText('Website Crawl')).toBeInTheDocument()
})
it('should call onClick with the card value and apply selected styles', () => {
const onClick = vi.fn()
render(
<OptionCard
label="Online Drive"
value="online-drive"
selected
nodeData={{ title: 'Online Drive' } as DataSourceNodeType}
onClick={onClick}
/>,
)
fireEvent.click(screen.getByText('Online Drive'))
expect(onClick).toHaveBeenCalledWith('online-drive')
expect(screen.getByText('Online Drive')).toHaveClass('text-text-primary')
})
})

View File

@ -0,0 +1,67 @@
import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions'
import { fireEvent, render, screen } from '@testing-library/react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import Actions from '../actions'
let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus } } | undefined
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { workflowRunningData: typeof mockWorkflowRunningData }) => unknown) => selector({
workflowRunningData: mockWorkflowRunningData,
}),
}))
const createFormParams = (overrides: Partial<CustomActionsProps> = {}): CustomActionsProps => ({
form: {
handleSubmit: vi.fn(),
} as unknown as CustomActionsProps['form'],
isSubmitting: false,
canSubmit: true,
...overrides,
})
describe('Document processing actions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowRunningData = undefined
})
it('should render back/process actions and trigger both callbacks', () => {
const onBack = vi.fn()
const formParams = createFormParams()
render(<Actions formParams={formParams} onBack={onBack} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.backToDataSource' }))
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.process' }))
expect(onBack).toHaveBeenCalledTimes(1)
expect(formParams.form.handleSubmit).toHaveBeenCalledTimes(1)
})
it('should disable processing when runDisabled or the workflow is already running', () => {
const { rerender } = render(
<Actions
formParams={createFormParams()}
onBack={vi.fn()}
runDisabled
/>,
)
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.process' })).toBeDisabled()
mockWorkflowRunningData = {
result: {
status: WorkflowRunningStatus.Running,
},
}
rerender(
<Actions
formParams={createFormParams()}
onBack={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i })).toBeDisabled()
})
})

View File

@ -0,0 +1,32 @@
import { renderHook } from '@testing-library/react'
import { useInputVariables } from '../hooks'
const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({
data: { variables: [{ variable: 'chunkSize' }] },
isFetching: true,
})))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }),
}))
vi.mock('@/service/use-pipeline', () => ({
useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams,
}))
describe('useInputVariables', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should query processing params with the current pipeline id and datasource node id', () => {
const { result } = renderHook(() => useInputVariables('datasource-node'))
expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({
pipeline_id: 'pipeline-1',
node_id: 'datasource-node',
})
expect(result.current.isFetchingParams).toBe(true)
expect(result.current.paramsConfig).toEqual({ variables: [{ variable: 'chunkSize' }] })
})
})

View File

@ -0,0 +1,140 @@
import type { ZodSchema } from 'zod'
import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions'
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Options from '../options'
const {
mockFormValue,
mockHandleSubmit,
mockToastError,
mockBaseField,
} = vi.hoisted(() => ({
mockFormValue: { chunkSize: 256 } as Record<string, unknown>,
mockHandleSubmit: vi.fn(),
mockToastError: vi.fn(),
mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => {
return function FieldComponent() {
return <div data-testid="base-field">{config.variable}</div>
}
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({
default: mockBaseField,
}))
vi.mock('@/app/components/base/form', () => ({
useAppForm: ({
onSubmit,
validators,
}: {
onSubmit: (params: { value: Record<string, unknown> }) => void
validators?: {
onSubmit?: (params: { value: Record<string, unknown> }) => string | undefined
}
}) => ({
handleSubmit: () => {
const validationResult = validators?.onSubmit?.({ value: mockFormValue })
if (!validationResult)
onSubmit({ value: mockFormValue })
mockHandleSubmit()
},
AppForm: ({ children }: { children: React.ReactNode }) => <div data-testid="app-form">{children}</div>,
Actions: ({ CustomActions }: { CustomActions: (props: CustomActionsProps) => React.ReactNode }) => (
<div data-testid="form-actions">
{CustomActions({
form: {
handleSubmit: mockHandleSubmit,
} as unknown as CustomActionsProps['form'],
isSubmitting: false,
canSubmit: true,
})}
</div>
),
}),
}))
const createSchema = (success: boolean): ZodSchema => ({
safeParse: vi.fn(() => {
if (success)
return { success: true }
return {
success: false,
error: {
issues: [{
path: ['chunkSize'],
message: 'Invalid value',
}],
},
}
}),
}) as unknown as ZodSchema
describe('Document processing options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render base fields and the custom actions slot', () => {
render(
<Options
initialData={{ chunkSize: 100 }}
configurations={[{ variable: 'chunkSize' } as BaseConfiguration]}
schema={createSchema(true)}
CustomActions={() => <div data-testid="custom-actions">custom actions</div>}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByTestId('base-field')).toHaveTextContent('chunkSize')
expect(screen.getByTestId('form-actions')).toBeInTheDocument()
expect(screen.getByTestId('custom-actions')).toBeInTheDocument()
})
it('should validate and toast the first schema error before submitting', async () => {
const onSubmit = vi.fn()
const { container } = render(
<Options
initialData={{ chunkSize: 100 }}
configurations={[]}
schema={createSchema(false)}
CustomActions={() => <div>actions</div>}
onSubmit={onSubmit}
/>,
)
fireEvent.submit(container.querySelector('form')!)
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('Path: chunkSize Error: Invalid value')
})
expect(onSubmit).not.toHaveBeenCalled()
})
it('should submit the parsed form value when validation succeeds', async () => {
const onSubmit = vi.fn()
const { container } = render(
<Options
initialData={{ chunkSize: 100 }}
configurations={[]}
schema={createSchema(true)}
CustomActions={() => <div>actions</div>}
onSubmit={onSubmit}
/>,
)
fireEvent.submit(container.querySelector('form')!)
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(mockFormValue)
})
})
})

View File

@ -0,0 +1,84 @@
import { ChunkingMode } from '@/models/datasets'
import { formatPreviewChunks } from '../utils'
vi.mock('@/config', () => ({
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 2,
}))
describe('result preview utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return undefined for empty outputs', () => {
expect(formatPreviewChunks(undefined)).toBeUndefined()
expect(formatPreviewChunks(null)).toBeUndefined()
})
it('should format text chunks and limit them to the preview length', () => {
const result = formatPreviewChunks({
chunk_structure: ChunkingMode.text,
preview: [
{ content: 'Chunk 1', summary: 'S1' },
{ content: 'Chunk 2', summary: 'S2' },
{ content: 'Chunk 3', summary: 'S3' },
],
})
expect(result).toEqual([
{ content: 'Chunk 1', summary: 'S1' },
{ content: 'Chunk 2', summary: 'S2' },
])
})
it('should format paragraph and full-doc parent-child previews differently', () => {
const paragraph = formatPreviewChunks({
chunk_structure: ChunkingMode.parentChild,
parent_mode: 'paragraph',
preview: [
{ content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] },
{ content: 'Parent 2', child_chunks: ['c4'] },
{ content: 'Parent 3', child_chunks: ['c5'] },
],
})
const fullDoc = formatPreviewChunks({
chunk_structure: ChunkingMode.parentChild,
parent_mode: 'full-doc',
preview: [
{ content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] },
],
})
expect(paragraph).toEqual({
parent_mode: 'paragraph',
parent_child_chunks: [
{ parent_content: 'Parent 1', parent_summary: undefined, child_contents: ['c1', 'c2', 'c3'], parent_mode: 'paragraph' },
{ parent_content: 'Parent 2', parent_summary: undefined, child_contents: ['c4'], parent_mode: 'paragraph' },
],
})
expect(fullDoc).toEqual({
parent_mode: 'full-doc',
parent_child_chunks: [
{ parent_content: 'Parent 1', child_contents: ['c1', 'c2'], parent_mode: 'full-doc' },
],
})
})
it('should format qa previews and limit them to the preview size', () => {
const result = formatPreviewChunks({
chunk_structure: ChunkingMode.qa,
qa_preview: [
{ question: 'Q1', answer: 'A1' },
{ question: 'Q2', answer: 'A2' },
{ question: 'Q3', answer: 'A3' },
],
})
expect(result).toEqual({
qa_chunks: [
{ question: 'Q1', answer: 'A1' },
{ question: 'Q2', answer: 'A2' },
],
})
})
})

View File

@ -0,0 +1,64 @@
import type { WorkflowRunningData } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import Tab from '../tab'
const createWorkflowRunningData = (): WorkflowRunningData => ({
task_id: 'task-1',
message_id: 'message-1',
conversation_id: 'conversation-1',
result: {
workflow_id: 'workflow-1',
inputs: '{}',
inputs_truncated: false,
process_data: '{}',
process_data_truncated: false,
outputs: '{}',
outputs_truncated: false,
status: 'succeeded',
elapsed_time: 10,
total_tokens: 20,
created_at: Date.now(),
finished_at: Date.now(),
steps: 1,
total_steps: 1,
},
tracing: [],
})
describe('Tab', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render an active tab and pass its value on click', () => {
const onClick = vi.fn()
render(
<Tab
isActive
label="Preview"
value="preview"
workflowRunningData={createWorkflowRunningData()}
onClick={onClick}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
expect(screen.getByRole('button')).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
expect(onClick).toHaveBeenCalledWith('preview')
})
it('should disable the tab when workflow run data is unavailable', () => {
render(
<Tab
isActive={false}
label="Trace"
value="trace"
onClick={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Trace' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Trace' })).toHaveClass('opacity-30')
})
})

Some files were not shown because too many files have changed in this diff Show More