test: add unit tests for access control components to enhance coverage and reliability

- Introduced new test files for AccessControlDialog, AccessControlItem, AddMemberOrGroupDialog, and SpecificGroupsOrMembers.
- Enhanced testing for rendering, interactions, and state management across various access control components.
- Ensured comprehensive coverage for user interactions and state updates in the access control context.
This commit is contained in:
CodingOnStar
2026-04-08 14:07:53 +08:00
parent 1f4e127af5
commit 1607c7c6a4
20 changed files with 1153 additions and 0 deletions

View File

@ -0,0 +1,16 @@
import { render, screen } from '@testing-library/react'
import HitHistoryNoData from '../hit-history-no-data'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
describe('HitHistoryNoData', () => {
it('should render the empty history message', () => {
render(<HitHistoryNoData />)
expect(screen.getByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,52 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import AccessControlDialog from '../access-control-dialog'
vi.mock('@headlessui/react', () => {
const DialogComponent: any = ({ children, className, ...rest }: any) => (
<div role="dialog" className={className} {...rest}>{children}</div>
)
DialogComponent.Panel = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const TransitionChild = ({ children }: any) => (
<>{typeof children === 'function' ? children({}) : children}</>
)
const Transition = ({ show = true, children }: any) => (
show ? <>{typeof children === 'function' ? children({}) : children}</> : null
)
Transition.Child = TransitionChild
return {
Dialog: DialogComponent,
Transition,
}
})
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()
const { container } = render(
<AccessControlDialog show onClose={onClose}>
<div>Dialog Content</div>
</AccessControlDialog>,
)
const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement
fireEvent.click(closeButton)
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

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

View File

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

View File

@ -0,0 +1,149 @@
/* 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('@headlessui/react', () => {
const DialogComponent: any = ({ children, className, ...rest }: any) => (
<div role="dialog" className={className} {...rest}>{children}</div>
)
DialogComponent.Panel = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const DialogTitle = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const DialogDescription = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const TransitionChild = ({ children }: any) => (
<>{typeof children === 'function' ? children({}) : children}</>
)
const Transition = ({ show = true, children }: any) => (
show ? <>{typeof children === 'function' ? children({}) : children}</> : null
)
Transition.Child = TransitionChild
return {
Dialog: DialogComponent,
Transition,
DialogTitle,
Description: DialogDescription,
}
})
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: typeof mockWebappAuth } }) => unknown) => selector({
systemFeatures: {
webapp_auth: mockWebappAuth,
},
}),
}))
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
describe('AccessControl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWebappAuth = {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
}
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockMutateAsync.mockResolvedValue(undefined)
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [],
members: [],
},
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
})
})
it('should initialize menu from the app and update access mode on confirm', async () => {
const onClose = vi.fn()
const onConfirm = vi.fn()
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const app = {
id: 'app-id-1',
access_mode: AccessMode.PUBLIC,
} as App
render(
<AccessControl
app={app}
onClose={onClose}
onConfirm={onConfirm}
/>,
)
await waitFor(() => {
expect(useAccessControlStore.getState().appId).toBe(app.id)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
appId: app.id,
accessMode: AccessMode.PUBLIC,
})
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
it('should show the external-members option when SSO tip is visible', () => {
mockWebappAuth = {
enabled: false,
allow_sso: false,
allow_email_password_login: false,
allow_email_code_login: false,
}
render(
<AccessControl
app={{ id: 'app-id-2', access_mode: AccessMode.PUBLIC } as App}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
})
})

View File

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

View File

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

View File

@ -0,0 +1,25 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ModalFoot from '../modal-foot'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
describe('ModalFoot', () => {
it('should trigger cancel and confirm callbacks', () => {
const onCancel = vi.fn()
const onConfirm = vi.fn()
render(
<ModalFoot onCancel={onCancel} onConfirm={onConfirm} />,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,22 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SelectVarType from '../select-var-type'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
describe('SelectVarType', () => {
it('should open the menu and return the selected variable type', () => {
const onChange = vi.fn()
render(<SelectVarType onChange={onChange} />)
fireEvent.click(screen.getByText('common.operation.add'))
fireEvent.click(screen.getByText('appDebug.variableConfig.checkbox'))
expect(onChange).toHaveBeenCalledWith('checkbox')
expect(screen.queryByText('appDebug.variableConfig.checkbox')).not.toBeInTheDocument()
})
})

View File

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

View File

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

View File

@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import Field from '../field'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
describe('ConfigModal Field', () => {
it('should render the title and children', () => {
render(
<Field title="Field title">
<input aria-label="field-input" />
</Field>,
)
expect(screen.getByText('Field title')).toBeInTheDocument()
expect(screen.getByLabelText('field-input')).toBeInTheDocument()
})
it('should render the optional hint when requested', () => {
render(
<Field title="Optional field" isOptional>
<input aria-label="optional-field-input" />
</Field>,
)
expect(screen.getByText(/\(appDebug\.variableConfig\.optional\)/)).toBeInTheDocument()
})
})

View File

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

View File

@ -0,0 +1,64 @@
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('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => {
const file: FileUpload = {
enabled: true,
allowed_file_types: [],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
image: {
enabled: true,
detail: Resolution.low,
number_limits: 3,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
},
...fileOverrides,
}
const featureStoreState = {
features: { file },
setFeatures: vi.fn(),
showFeaturesModal: false,
setShowFeaturesModal: vi.fn(),
} as unknown as FeatureStoreState
mockUseFeatures.mockImplementation(selector => selector(featureStoreState))
mockUseFeaturesStore.mockReturnValue({
getState: () => featureStoreState,
})
}
describe('ParamConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
setupFeatureStore()
})
it('should toggle the settings panel when clicking the trigger', async () => {
const user = userEvent.setup()
render(<ParamConfig />)
expect(screen.queryByText('appDebug.vision.visionSettings.title')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'appDebug.voice.settings' }))
expect(await screen.findByText('appDebug.vision.visionSettings.title')).toBeInTheDocument()
})
})

View File

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

View File

@ -0,0 +1,16 @@
import { render, screen } from '@testing-library/react'
import ResPlaceholder from '../res-placeholder'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
describe('ResPlaceholder', () => {
it('should render the placeholder copy', () => {
render(<ResPlaceholder />)
expect(screen.getByText('appDebug.generate.newNoDataLine1')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,47 @@
import type { GenRes } from '@/service/debug'
import { act, renderHook } from '@testing-library/react'
import useGenData from '../use-gen-data'
vi.mock('ahooks', async (importOriginal) => {
const React = await import('react')
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useSessionStorageState: <T>(_key: string, options: { defaultValue: T }) => {
const [value, setValue] = React.useState(options.defaultValue)
return [value, setValue] as const
},
}
})
describe('useGenData', () => {
it('should start with an empty version list', () => {
const { result } = renderHook(() => useGenData({ storageKey: 'prompt' }))
expect(result.current.versions).toEqual([])
expect(result.current.currentVersionIndex).toBe(0)
expect(result.current.current).toBeUndefined()
})
it('should append versions and keep the latest one selected', () => {
const versionOne = { modified: 'first version' } as GenRes
const versionTwo = { modified: 'second version' } as GenRes
const { result } = renderHook(() => useGenData({ storageKey: 'prompt' }))
act(() => {
result.current.addVersion(versionOne)
})
expect(result.current.versions).toEqual([versionOne])
expect(result.current.current).toEqual(versionOne)
act(() => {
result.current.addVersion(versionTwo)
})
expect(result.current.versions).toEqual([versionOne, versionTwo])
expect(result.current.currentVersionIndex).toBe(1)
expect(result.current.current).toEqual(versionTwo)
})
})

View File

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

View File

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

View File

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