+
)}
diff --git a/web/app/components/billing/priority-label/index.spec.tsx b/web/app/components/billing/priority-label/index.spec.tsx
new file mode 100644
index 0000000000..0d176d1611
--- /dev/null
+++ b/web/app/components/billing/priority-label/index.spec.tsx
@@ -0,0 +1,125 @@
+import type { Mock } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { createMockPlan } from '@/__mocks__/provider-context'
+import { useProviderContext } from '@/context/provider-context'
+import { Plan } from '../type'
+import PriorityLabel from './index'
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: vi.fn(),
+}))
+
+const useProviderContextMock = useProviderContext as Mock
+
+const setupPlan = (planType: Plan) => {
+ useProviderContextMock.mockReturnValue(createMockPlan(planType))
+}
+
+describe('PriorityLabel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering: basic label output for sandbox plan.
+ describe('Rendering', () => {
+ it('should render the standard priority label when plan is sandbox', () => {
+ // Arrange
+ setupPlan(Plan.sandbox)
+
+ // Act
+ render(
)
+
+ // Assert
+ expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
+ })
+ })
+
+ // Props: custom class name applied to the label container.
+ describe('Props', () => {
+ it('should apply custom className to the label container', () => {
+ // Arrange
+ setupPlan(Plan.sandbox)
+
+ // Act
+ render(
)
+
+ // Assert
+ const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
+ expect(label).toHaveClass('custom-class')
+ })
+ })
+
+ // Plan types: label text and icon visibility for different plans.
+ describe('Plan Types', () => {
+ it('should render priority label and icon when plan is professional', () => {
+ // Arrange
+ setupPlan(Plan.professional)
+
+ // Act
+ const { container } = render(
)
+
+ // Assert
+ expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument()
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should render top priority label and icon when plan is team', () => {
+ // Arrange
+ setupPlan(Plan.team)
+
+ // Act
+ const { container } = render(
)
+
+ // Assert
+ expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should render standard label without icon when plan is sandbox', () => {
+ // Arrange
+ setupPlan(Plan.sandbox)
+
+ // Act
+ const { container } = render(
)
+
+ // Assert
+ expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
+ expect(container.querySelector('svg')).not.toBeInTheDocument()
+ })
+ })
+
+ // Edge cases: tooltip content varies by priority level.
+ describe('Edge Cases', () => {
+ it('should show the tip text when priority is not top priority', async () => {
+ // Arrange
+ setupPlan(Plan.sandbox)
+
+ // Act
+ render(
)
+ const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
+ fireEvent.mouseEnter(label as HTMLElement)
+
+ // Assert
+ expect(await screen.findByText(
+ 'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard',
+ )).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.documentProcessingPriorityTip')).toBeInTheDocument()
+ })
+
+ it('should hide the tip text when priority is top priority', async () => {
+ // Arrange
+ setupPlan(Plan.enterprise)
+
+ // Act
+ render(
)
+ const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div')
+ fireEvent.mouseEnter(label as HTMLElement)
+
+ // Assert
+ expect(await screen.findByText(
+ 'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority',
+ )).toBeInTheDocument()
+ expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityTip')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/index.spec.tsx
new file mode 100644
index 0000000000..e73fcdf0ad
--- /dev/null
+++ b/web/app/components/explore/app-list/index.spec.tsx
@@ -0,0 +1,271 @@
+import type { Mock } from 'vitest'
+import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
+import type { App } from '@/models/explore'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import ExploreContext from '@/context/explore-context'
+import { fetchAppDetail } from '@/service/explore'
+import { AppModeEnum } from '@/types/app'
+import AppList from './index'
+
+const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
+let mockTabValue = allCategoriesEn
+const mockSetTab = vi.fn()
+let mockSWRData: { categories: string[], allList: App[] } = { categories: [], allList: [] }
+const mockHandleImportDSL = vi.fn()
+const mockHandleImportDSLConfirm = vi.fn()
+
+vi.mock('@/hooks/use-tab-searchparams', () => ({
+ useTabSearchParams: () => [mockTabValue, mockSetTab],
+}))
+
+vi.mock('ahooks', async () => {
+ const actual = await vi.importActual
('ahooks')
+ const React = await vi.importActual('react')
+ return {
+ ...actual,
+ useDebounceFn: (fn: (...args: unknown[]) => void) => {
+ const fnRef = React.useRef(fn)
+ fnRef.current = fn
+ return {
+ run: () => setTimeout(() => fnRef.current(), 0),
+ }
+ },
+ }
+})
+
+vi.mock('swr', () => ({
+ __esModule: true,
+ default: () => ({ data: mockSWRData }),
+}))
+
+vi.mock('@/service/explore', () => ({
+ fetchAppDetail: vi.fn(),
+ fetchAppList: vi.fn(),
+}))
+
+vi.mock('@/hooks/use-import-dsl', () => ({
+ useImportDSL: () => ({
+ handleImportDSL: mockHandleImportDSL,
+ handleImportDSLConfirm: mockHandleImportDSLConfirm,
+ versions: ['v1'],
+ isFetching: false,
+ }),
+}))
+
+vi.mock('@/app/components/explore/create-app-modal', () => ({
+ __esModule: true,
+ default: (props: CreateAppModalProps) => {
+ if (!props.show)
+ return null
+ return (
+
+
+
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
+ __esModule: true,
+ default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => (
+
+
+
+
+ ),
+}))
+
+const createApp = (overrides: Partial = {}): App => ({
+ app: {
+ id: overrides.app?.id ?? 'app-basic-id',
+ mode: overrides.app?.mode ?? AppModeEnum.CHAT,
+ icon_type: overrides.app?.icon_type ?? 'emoji',
+ icon: overrides.app?.icon ?? '😀',
+ icon_background: overrides.app?.icon_background ?? '#fff',
+ icon_url: overrides.app?.icon_url ?? '',
+ name: overrides.app?.name ?? 'Alpha',
+ description: overrides.app?.description ?? 'Alpha description',
+ use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
+ },
+ app_id: overrides.app_id ?? 'app-1',
+ description: overrides.description ?? 'Alpha description',
+ copyright: overrides.copyright ?? '',
+ privacy_policy: overrides.privacy_policy ?? null,
+ custom_disclaimer: overrides.custom_disclaimer ?? null,
+ category: overrides.category ?? 'Writing',
+ position: overrides.position ?? 1,
+ is_listed: overrides.is_listed ?? true,
+ install_count: overrides.install_count ?? 0,
+ installed: overrides.installed ?? false,
+ editable: overrides.editable ?? false,
+ is_agent: overrides.is_agent ?? false,
+})
+
+const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('AppList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTabValue = allCategoriesEn
+ mockSWRData = { categories: [], allList: [] }
+ })
+
+ // Rendering: show loading when categories are not ready.
+ describe('Rendering', () => {
+ it('should render loading when categories are empty', () => {
+ // Arrange
+ mockSWRData = { categories: [], allList: [] }
+
+ // Act
+ renderWithContext()
+
+ // Assert
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should render app cards when data is available', () => {
+ // Arrange
+ mockSWRData = {
+ categories: ['Writing', 'Translate'],
+ allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
+ }
+
+ // Act
+ renderWithContext()
+
+ // Assert
+ expect(screen.getByText('Alpha')).toBeInTheDocument()
+ expect(screen.getByText('Beta')).toBeInTheDocument()
+ })
+ })
+
+ // Props: category selection filters the list.
+ describe('Props', () => {
+ it('should filter apps by selected category', () => {
+ // Arrange
+ mockTabValue = 'Writing'
+ mockSWRData = {
+ categories: ['Writing', 'Translate'],
+ allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
+ }
+
+ // Act
+ renderWithContext()
+
+ // Assert
+ expect(screen.getByText('Alpha')).toBeInTheDocument()
+ expect(screen.queryByText('Beta')).not.toBeInTheDocument()
+ })
+ })
+
+ // User interactions: search and create flow.
+ describe('User Interactions', () => {
+ it('should filter apps by search keywords', async () => {
+ // Arrange
+ mockSWRData = {
+ categories: ['Writing'],
+ allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
+ }
+ renderWithContext()
+
+ // Act
+ const input = screen.getByPlaceholderText('common.operation.search')
+ fireEvent.change(input, { target: { value: 'gam' } })
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
+ expect(screen.getByText('Gamma')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle create flow and confirm DSL when pending', async () => {
+ // Arrange
+ const onSuccess = vi.fn()
+ mockSWRData = {
+ categories: ['Writing'],
+ allList: [createApp()],
+ };
+ (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' })
+ mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
+ options.onPending?.()
+ })
+ mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
+ options.onSuccess?.()
+ })
+
+ // Act
+ renderWithContext(true, onSuccess)
+ fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
+ fireEvent.click(await screen.findByTestId('confirm-create'))
+
+ // Assert
+ await waitFor(() => {
+ expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id')
+ })
+ expect(mockHandleImportDSL).toHaveBeenCalledTimes(1)
+ expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('dsl-confirm'))
+ await waitFor(() => {
+ expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
+ expect(onSuccess).toHaveBeenCalledTimes(1)
+ })
+ })
+ })
+
+ // Edge cases: handle clearing search keywords.
+ describe('Edge Cases', () => {
+ it('should reset search results when clear icon is clicked', async () => {
+ // Arrange
+ mockSWRData = {
+ categories: ['Writing'],
+ allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
+ }
+ renderWithContext()
+
+ // Act
+ const input = screen.getByPlaceholderText('common.operation.search')
+ fireEvent.change(input, { target: { value: 'gam' } })
+ await waitFor(() => {
+ expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('input-clear'))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('Alpha')).toBeInTheDocument()
+ expect(screen.getByText('Gamma')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/explore/category.spec.tsx b/web/app/components/explore/category.spec.tsx
new file mode 100644
index 0000000000..a84b17c844
--- /dev/null
+++ b/web/app/components/explore/category.spec.tsx
@@ -0,0 +1,79 @@
+import type { AppCategory } from '@/models/explore'
+import { fireEvent, render, screen } from '@testing-library/react'
+import Category from './category'
+
+describe('Category', () => {
+ const allCategoriesEn = 'Recommended'
+
+ const renderComponent = (overrides: Partial> = {}) => {
+ const props: React.ComponentProps = {
+ list: ['Writing', 'Recommended'] as AppCategory[],
+ value: allCategoriesEn,
+ onChange: vi.fn(),
+ allCategoriesEn,
+ ...overrides,
+ }
+ return {
+ props,
+ ...render(),
+ }
+ }
+
+ // Rendering: basic categories and all-categories button.
+ describe('Rendering', () => {
+ it('should render all categories item and translated categories', () => {
+ // Arrange
+ renderComponent()
+
+ // Assert
+ expect(screen.getByText('explore.apps.allCategories')).toBeInTheDocument()
+ expect(screen.getByText('explore.category.Writing')).toBeInTheDocument()
+ })
+
+ it('should not render allCategoriesEn again inside the category list', () => {
+ // Arrange
+ renderComponent()
+
+ // Assert
+ const recommendedItems = screen.getAllByText('explore.apps.allCategories')
+ expect(recommendedItems).toHaveLength(1)
+ })
+ })
+
+ // Props: clicking items triggers onChange.
+ describe('Props', () => {
+ it('should call onChange with category value when category item is clicked', () => {
+ // Arrange
+ const { props } = renderComponent()
+
+ // Act
+ fireEvent.click(screen.getByText('explore.category.Writing'))
+
+ // Assert
+ expect(props.onChange).toHaveBeenCalledWith('Writing')
+ })
+
+ it('should call onChange with allCategoriesEn when all categories is clicked', () => {
+ // Arrange
+ const { props } = renderComponent({ value: 'Writing' })
+
+ // Act
+ fireEvent.click(screen.getByText('explore.apps.allCategories'))
+
+ // Assert
+ expect(props.onChange).toHaveBeenCalledWith(allCategoriesEn)
+ })
+ })
+
+ // Edge cases: handle values not in the list.
+ describe('Edge Cases', () => {
+ it('should treat unknown value as all categories selection', () => {
+ // Arrange
+ renderComponent({ value: 'Unknown' })
+
+ // Assert
+ const allCategoriesItem = screen.getByText('explore.apps.allCategories')
+ expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active')
+ })
+ })
+})
diff --git a/web/app/components/explore/index.spec.tsx b/web/app/components/explore/index.spec.tsx
new file mode 100644
index 0000000000..8f361ad471
--- /dev/null
+++ b/web/app/components/explore/index.spec.tsx
@@ -0,0 +1,140 @@
+import type { Mock } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import { useContext } from 'use-context-selector'
+import { useAppContext } from '@/context/app-context'
+import ExploreContext from '@/context/explore-context'
+import { MediaType } from '@/hooks/use-breakpoints'
+import useDocumentTitle from '@/hooks/use-document-title'
+import { useMembers } from '@/service/use-common'
+import Explore from './index'
+
+const mockReplace = vi.fn()
+const mockPush = vi.fn()
+const mockInstalledAppsData = { installed_apps: [] as const }
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ replace: mockReplace,
+ push: mockPush,
+ }),
+ useSelectedLayoutSegments: () => ['apps'],
+}))
+
+vi.mock('@/hooks/use-breakpoints', () => ({
+ __esModule: true,
+ default: () => MediaType.pc,
+ MediaType: {
+ mobile: 'mobile',
+ tablet: 'tablet',
+ pc: 'pc',
+ },
+}))
+
+vi.mock('@/service/use-explore', () => ({
+ useGetInstalledApps: () => ({
+ isFetching: false,
+ data: mockInstalledAppsData,
+ refetch: vi.fn(),
+ }),
+ useUninstallApp: () => ({
+ mutateAsync: vi.fn(),
+ }),
+ useUpdateAppPinStatus: () => ({
+ mutateAsync: vi.fn(),
+ }),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+vi.mock('@/service/use-common', () => ({
+ useMembers: vi.fn(),
+}))
+
+vi.mock('@/hooks/use-document-title', () => ({
+ __esModule: true,
+ default: vi.fn(),
+}))
+
+const ContextReader = () => {
+ const { hasEditPermission } = useContext(ExploreContext)
+ return {hasEditPermission ? 'edit-yes' : 'edit-no'}
+}
+
+describe('Explore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering: provides ExploreContext and children.
+ describe('Rendering', () => {
+ it('should render children and provide edit permission from members role', async () => {
+ // Arrange
+ ; (useAppContext as Mock).mockReturnValue({
+ userProfile: { id: 'user-1' },
+ isCurrentWorkspaceDatasetOperator: false,
+ });
+ (useMembers as Mock).mockReturnValue({
+ data: {
+ accounts: [{ id: 'user-1', role: 'admin' }],
+ },
+ })
+
+ // Act
+ render((
+
+
+
+ ))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('edit-yes')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // Effects: set document title and redirect dataset operators.
+ describe('Effects', () => {
+ it('should set document title on render', () => {
+ // Arrange
+ ; (useAppContext as Mock).mockReturnValue({
+ userProfile: { id: 'user-1' },
+ isCurrentWorkspaceDatasetOperator: false,
+ });
+ (useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
+
+ // Act
+ render((
+
+ child
+
+ ))
+
+ // Assert
+ expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore')
+ })
+
+ it('should redirect dataset operators to /datasets', async () => {
+ // Arrange
+ ; (useAppContext as Mock).mockReturnValue({
+ userProfile: { id: 'user-1' },
+ isCurrentWorkspaceDatasetOperator: true,
+ });
+ (useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
+
+ // Act
+ render((
+
+ child
+
+ ))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenCalledWith('/datasets')
+ })
+ })
+ })
+})
diff --git a/web/app/components/explore/item-operation/index.spec.tsx b/web/app/components/explore/item-operation/index.spec.tsx
new file mode 100644
index 0000000000..9084e5564e
--- /dev/null
+++ b/web/app/components/explore/item-operation/index.spec.tsx
@@ -0,0 +1,109 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import ItemOperation from './index'
+
+describe('ItemOperation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const renderComponent = (overrides: Partial> = {}) => {
+ const props: React.ComponentProps = {
+ isPinned: false,
+ isShowDelete: true,
+ togglePin: vi.fn(),
+ onDelete: vi.fn(),
+ ...overrides,
+ }
+ return {
+ props,
+ ...render(),
+ }
+ }
+
+ // Rendering: menu items show after opening.
+ describe('Rendering', () => {
+ it('should render pin and delete actions when menu is open', async () => {
+ // Arrange
+ renderComponent()
+
+ // Act
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+
+ // Assert
+ expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument()
+ expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument()
+ })
+ })
+
+ // Props: render optional rename action and pinned label text.
+ describe('Props', () => {
+ it('should render rename action when isShowRenameConversation is true', async () => {
+ // Arrange
+ renderComponent({ isShowRenameConversation: true })
+
+ // Act
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+
+ // Assert
+ expect(await screen.findByText('explore.sidebar.action.rename')).toBeInTheDocument()
+ })
+
+ it('should render unpin label when isPinned is true', async () => {
+ // Arrange
+ renderComponent({ isPinned: true })
+
+ // Act
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+
+ // Assert
+ expect(await screen.findByText('explore.sidebar.action.unpin')).toBeInTheDocument()
+ })
+ })
+
+ // User interactions: clicking action items triggers callbacks.
+ describe('User Interactions', () => {
+ it('should call togglePin when clicking pin action', async () => {
+ // Arrange
+ const { props } = renderComponent()
+
+ // Act
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+ fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
+
+ // Assert
+ expect(props.togglePin).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onDelete when clicking delete action', async () => {
+ // Arrange
+ const { props } = renderComponent()
+
+ // Act
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+ fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
+
+ // Assert
+ expect(props.onDelete).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Edge cases: menu closes after mouse leave when no hovering state remains.
+ describe('Edge Cases', () => {
+ it('should close the menu when mouse leaves the panel and item is not hovering', async () => {
+ // Arrange
+ renderComponent()
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+ const pinText = await screen.findByText('explore.sidebar.action.pin')
+ const menu = pinText.closest('div')?.parentElement as HTMLElement
+
+ // Act
+ fireEvent.mouseEnter(menu)
+ fireEvent.mouseLeave(menu)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/explore/item-operation/index.tsx b/web/app/components/explore/item-operation/index.tsx
index 3703c0d4c0..abbfdd09bd 100644
--- a/web/app/components/explore/item-operation/index.tsx
+++ b/web/app/components/explore/item-operation/index.tsx
@@ -53,7 +53,11 @@ const ItemOperation: FC = ({
setOpen(v => !v)}
>
-
+
+
({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}))
+
+vi.mock('ahooks', async () => {
+ const actual = await vi.importActual('ahooks')
+ return {
+ ...actual,
+ useHover: () => false,
+ }
+})
+
+const baseProps = {
+ isMobile: false,
+ name: 'My App',
+ id: 'app-123',
+ icon_type: 'emoji' as const,
+ icon: '🤖',
+ icon_background: '#fff',
+ icon_url: '',
+ isSelected: false,
+ isPinned: false,
+ togglePin: vi.fn(),
+ uninstallable: false,
+ onDelete: vi.fn(),
+}
+
+describe('AppNavItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering: display app name for desktop and hide for mobile.
+ describe('Rendering', () => {
+ it('should render name and item operation on desktop', () => {
+ // Arrange
+ render()
+
+ // Assert
+ expect(screen.getByText('My App')).toBeInTheDocument()
+ expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument()
+ })
+
+ it('should hide name on mobile', () => {
+ // Arrange
+ render()
+
+ // Assert
+ expect(screen.queryByText('My App')).not.toBeInTheDocument()
+ })
+ })
+
+ // User interactions: navigation and delete flow.
+ describe('User Interactions', () => {
+ it('should navigate to installed app when item is clicked', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText('My App'))
+
+ // Assert
+ expect(mockPush).toHaveBeenCalledWith('/explore/installed/app-123')
+ })
+
+ it('should call onDelete with app id when delete action is clicked', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+ fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
+
+ // Assert
+ expect(baseProps.onDelete).toHaveBeenCalledWith('app-123')
+ })
+ })
+
+ // Edge cases: hide delete when uninstallable or selected.
+ describe('Edge Cases', () => {
+ it('should not render delete action when app is uninstallable', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+
+ // Assert
+ expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/explore/sidebar/index.spec.tsx b/web/app/components/explore/sidebar/index.spec.tsx
new file mode 100644
index 0000000000..0cbd05aa08
--- /dev/null
+++ b/web/app/components/explore/sidebar/index.spec.tsx
@@ -0,0 +1,164 @@
+import type { InstalledApp } from '@/models/explore'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import Toast from '@/app/components/base/toast'
+import ExploreContext from '@/context/explore-context'
+import { MediaType } from '@/hooks/use-breakpoints'
+import { AppModeEnum } from '@/types/app'
+import SideBar from './index'
+
+const mockSegments = ['apps']
+const mockPush = vi.fn()
+const mockRefetch = vi.fn()
+const mockUninstall = vi.fn()
+const mockUpdatePinStatus = vi.fn()
+let mockIsFetching = false
+let mockInstalledApps: InstalledApp[] = []
+
+vi.mock('next/navigation', () => ({
+ useSelectedLayoutSegments: () => mockSegments,
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}))
+
+vi.mock('@/hooks/use-breakpoints', () => ({
+ __esModule: true,
+ default: () => MediaType.pc,
+ MediaType: {
+ mobile: 'mobile',
+ tablet: 'tablet',
+ pc: 'pc',
+ },
+}))
+
+vi.mock('@/service/use-explore', () => ({
+ useGetInstalledApps: () => ({
+ isFetching: mockIsFetching,
+ data: { installed_apps: mockInstalledApps },
+ refetch: mockRefetch,
+ }),
+ useUninstallApp: () => ({
+ mutateAsync: mockUninstall,
+ }),
+ useUpdateAppPinStatus: () => ({
+ mutateAsync: mockUpdatePinStatus,
+ }),
+}))
+
+const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({
+ id: overrides.id ?? 'app-123',
+ uninstallable: overrides.uninstallable ?? false,
+ is_pinned: overrides.is_pinned ?? false,
+ app: {
+ id: overrides.app?.id ?? 'app-basic-id',
+ mode: overrides.app?.mode ?? AppModeEnum.CHAT,
+ icon_type: overrides.app?.icon_type ?? 'emoji',
+ icon: overrides.app?.icon ?? '🤖',
+ icon_background: overrides.app?.icon_background ?? '#fff',
+ icon_url: overrides.app?.icon_url ?? '',
+ name: overrides.app?.name ?? 'My App',
+ description: overrides.app?.description ?? 'desc',
+ use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
+ },
+})
+
+const renderWithContext = (installedApps: InstalledApp[] = []) => {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('SideBar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsFetching = false
+ mockInstalledApps = []
+ vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
+ })
+
+ // Rendering: show discovery and workspace section.
+ describe('Rendering', () => {
+ it('should render workspace items when installed apps exist', () => {
+ // Arrange
+ mockInstalledApps = [createInstalledApp()]
+
+ // Act
+ renderWithContext(mockInstalledApps)
+
+ // Assert
+ expect(screen.getByText('explore.sidebar.discovery')).toBeInTheDocument()
+ expect(screen.getByText('explore.sidebar.workspace')).toBeInTheDocument()
+ expect(screen.getByText('My App')).toBeInTheDocument()
+ })
+ })
+
+ // Effects: refresh and sync installed apps state.
+ describe('Effects', () => {
+ it('should refetch installed apps on mount', () => {
+ // Arrange
+ mockInstalledApps = [createInstalledApp()]
+
+ // Act
+ renderWithContext(mockInstalledApps)
+
+ // Assert
+ expect(mockRefetch).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // User interactions: delete and pin flows.
+ describe('User Interactions', () => {
+ it('should uninstall app and show toast when delete is confirmed', async () => {
+ // Arrange
+ mockInstalledApps = [createInstalledApp()]
+ mockUninstall.mockResolvedValue(undefined)
+ renderWithContext(mockInstalledApps)
+
+ // Act
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+ fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
+ fireEvent.click(await screen.findByText('common.operation.confirm'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockUninstall).toHaveBeenCalledWith('app-123')
+ expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ message: 'common.api.remove',
+ }))
+ })
+ })
+
+ it('should update pin status and show toast when pin is clicked', async () => {
+ // Arrange
+ mockInstalledApps = [createInstalledApp({ is_pinned: false })]
+ mockUpdatePinStatus.mockResolvedValue(undefined)
+ renderWithContext(mockInstalledApps)
+
+ // Act
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+ fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true })
+ expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ message: 'common.api.success',
+ }))
+ })
+ })
+ })
+})