mirror of
https://github.com/langgenius/dify.git
synced 2026-04-15 18:06:36 +08:00
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:
parent
0e0bb3582f
commit
9948a51b14
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
107
web/app/components/app/overview/__tests__/app-card-utils.spec.ts
Normal file
107
web/app/components/app/overview/__tests__/app-card-utils.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
40
web/app/components/plugins/__tests__/constants.spec.ts
Normal file
40
web/app/components/plugins/__tests__/constants.spec.ts
Normal 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,
|
||||
])
|
||||
})
|
||||
})
|
||||
104
web/app/components/plugins/__tests__/provider-card.spec.tsx
Normal file
104
web/app/components/plugins/__tests__/provider-card.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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'],
|
||||
}])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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])
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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'],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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(),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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' }] })
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
Loading…
Reference in New Issue
Block a user