diff --git a/web/app/components/header/ maintenance-notice.spec.tsx b/web/app/components/header/ maintenance-notice.spec.tsx
new file mode 100644
index 0000000000..157b03eb17
--- /dev/null
+++ b/web/app/components/header/ maintenance-notice.spec.tsx
@@ -0,0 +1,120 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { vi } from 'vitest'
+import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
+import { NOTICE_I18N } from '@/i18n-config/language'
+import MaintenanceNotice from './maintenance-notice'
+
+vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
+ X: ({ onClick }: { onClick?: () => void }) => ,
+}))
+
+vi.mock(
+ '@/app/components/header/account-setting/model-provider-page/hooks',
+ () => ({
+ useLanguage: vi.fn(),
+ }),
+)
+
+vi.mock('@/i18n-config/language', async (importOriginal) => {
+ const actual = (await importOriginal()) as Record
+ return {
+ ...actual,
+ NOTICE_I18N: {
+ title: {
+ en_US: 'Notice Title',
+ zh_Hans: '提示标题',
+ },
+ desc: {
+ en_US: 'Notice Description',
+ zh_Hans: '提示描述',
+ },
+ href: '#',
+ },
+ }
+})
+
+describe('MaintenanceNotice', () => {
+ const windowOpenSpy = vi
+ .spyOn(window, 'open')
+ .mockImplementation(() => null)
+ const setNoticeHref = (href: string) => {
+ NOTICE_I18N.href = href
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ vi.mocked(useLanguage).mockReturnValue('en_US')
+ setNoticeHref('#')
+ })
+
+ afterAll(() => {
+ windowOpenSpy.mockRestore()
+ })
+
+ describe('Rendering', () => {
+ it('should render localized content correctly (English)', () => {
+ render()
+ expect(screen.getByText('Notice Title')).toBeInTheDocument()
+ expect(screen.getByText('Notice Description')).toBeInTheDocument()
+ })
+
+ it('should render localized content correctly (Chinese)', () => {
+ vi.mocked(useLanguage).mockReturnValue('zh_Hans')
+ render()
+ expect(screen.getByText('提示标题')).toBeInTheDocument()
+ expect(screen.getByText('提示描述')).toBeInTheDocument()
+ })
+
+ it('should not render when hidden in localStorage', () => {
+ localStorage.setItem('hide-maintenance-notice', '1')
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should close the notice when X is clicked', () => {
+ render()
+ expect(screen.getByText('Notice Title')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: /close notice/i }))
+
+ expect(screen.queryByText('Notice Title')).not.toBeInTheDocument()
+ expect(localStorage.getItem('hide-maintenance-notice')).toBe('1')
+ })
+
+ it('should jump to notice when description is clicked and href is valid', () => {
+ setNoticeHref('https://dify.ai/notice')
+ render()
+
+ const desc = screen.getByText('Notice Description')
+ fireEvent.click(desc)
+
+ expect(windowOpenSpy).toHaveBeenCalledWith(
+ 'https://dify.ai/notice',
+ '_blank',
+ )
+ })
+
+ it('should not jump when href is #', () => {
+ setNoticeHref('#')
+ render()
+
+ const desc = screen.getByText('Notice Description')
+ fireEvent.click(desc)
+
+ expect(windowOpenSpy).not.toHaveBeenCalled()
+ })
+
+ it('should not jump when href is empty', () => {
+ setNoticeHref('')
+ render()
+
+ const desc = screen.getByText('Notice Description')
+ fireEvent.click(desc)
+
+ expect(windowOpenSpy).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/header/app-back/index.spec.tsx b/web/app/components/header/app-back/index.spec.tsx
new file mode 100644
index 0000000000..d80ae1240c
--- /dev/null
+++ b/web/app/components/header/app-back/index.spec.tsx
@@ -0,0 +1,36 @@
+import type { App } from '@/types/app'
+import { fireEvent, render, screen } from '@testing-library/react'
+import AppBack from './index'
+
+describe('AppBack', () => {
+ const mockApp = {
+ id: 'test-app',
+ name: 'Test App',
+ } as App
+
+ it('should render apps label', () => {
+ render()
+ expect(screen.getByText('common.menus.apps')).toBeInTheDocument()
+ })
+
+ it('should keep apps label visible while hovering', () => {
+ render()
+ const label = screen.getByText('common.menus.apps')
+
+ fireEvent.mouseEnter(label)
+ expect(label).toBeInTheDocument()
+ fireEvent.mouseLeave(label)
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should render with different apps', () => {
+ const app1 = { id: 'app-1' } as App
+ const app2 = { id: 'app-2' } as App
+
+ const { rerender } = render()
+ expect(screen.getByText('common.menus.apps')).toBeInTheDocument()
+
+ rerender()
+ expect(screen.getByText('common.menus.apps')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/header/app-nav/index.spec.tsx b/web/app/components/header/app-nav/index.spec.tsx
new file mode 100644
index 0000000000..7dead323b5
--- /dev/null
+++ b/web/app/components/header/app-nav/index.spec.tsx
@@ -0,0 +1,267 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useParams } from 'next/navigation'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import { useAppContext } from '@/context/app-context'
+import { useInfiniteAppList } from '@/service/use-apps'
+import { AppModeEnum } from '@/types/app'
+import AppNav from './index'
+
+vi.mock('next/navigation', () => ({
+ useParams: vi.fn(),
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+vi.mock('@/app/components/app/store', () => ({
+ useStore: vi.fn(),
+}))
+
+vi.mock('@/service/use-apps', () => ({
+ useInfiniteAppList: vi.fn(),
+}))
+
+vi.mock('@/app/components/app/create-app-dialog', () => ({
+ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) =>
+ show
+ ? (
+
+ )
+ : null,
+}))
+
+vi.mock('@/app/components/app/create-app-modal', () => ({
+ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) =>
+ show
+ ? (
+
+ )
+ : null,
+}))
+
+vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
+ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) =>
+ show
+ ? (
+
+ )
+ : null,
+}))
+
+vi.mock('../nav', () => ({
+ default: ({
+ onCreate,
+ onLoadMore,
+ navigationItems,
+ }: {
+ onCreate: (state: string) => void
+ onLoadMore?: () => void
+ navigationItems?: Array<{ id: string, name: string, link: string }>
+ }) => (
+
+
+ {(navigationItems ?? []).map(item => (
+ - {`${item.name} -> ${item.link}`}
+ ))}
+
+
+
+
+
+
+ ),
+}))
+
+const mockAppData = [
+ {
+ id: 'app-1',
+ name: 'App 1',
+ mode: AppModeEnum.AGENT_CHAT,
+ icon_type: 'emoji',
+ icon: '🤖',
+ icon_background: null,
+ icon_url: null,
+ },
+]
+
+const mockUseParams = vi.mocked(useParams)
+const mockUseAppContext = vi.mocked(useAppContext)
+const mockUseAppStore = vi.mocked(useAppStore)
+const mockUseInfiniteAppList = vi.mocked(useInfiniteAppList)
+let mockAppDetail: { id: string, name: string } | null = null
+
+const setupDefaultMocks = (options?: {
+ hasNextPage?: boolean
+ refetch?: () => void
+ fetchNextPage?: () => void
+ isEditor?: boolean
+ appData?: typeof mockAppData
+}) => {
+ const refetch = options?.refetch ?? vi.fn()
+ const fetchNextPage = options?.fetchNextPage ?? vi.fn()
+
+ mockUseParams.mockReturnValue({ appId: 'app-1' } as ReturnType)
+ mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType)
+ mockUseAppStore.mockImplementation((selector: unknown) => (selector as (state: { appDetail: { id: string, name: string } | null }) => unknown)({ appDetail: mockAppDetail }))
+ mockUseInfiniteAppList.mockReturnValue({
+ data: { pages: [{ data: options?.appData ?? mockAppData }] },
+ fetchNextPage,
+ hasNextPage: options?.hasNextPage ?? false,
+ isFetchingNextPage: false,
+ refetch,
+ } as ReturnType)
+
+ return { refetch, fetchNextPage }
+}
+
+describe('AppNav', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAppDetail = null
+ setupDefaultMocks()
+ })
+
+ it('should build editor links and update app name when app detail changes', async () => {
+ setupDefaultMocks({
+ isEditor: true,
+ appData: [
+ {
+ id: 'app-1',
+ name: 'App 1',
+ mode: AppModeEnum.AGENT_CHAT,
+ icon_type: 'emoji',
+ icon: '🤖',
+ icon_background: null,
+ icon_url: null,
+ },
+ {
+ id: 'app-2',
+ name: 'App 2',
+ mode: AppModeEnum.WORKFLOW,
+ icon_type: 'emoji',
+ icon: '⚙️',
+ icon_background: null,
+ icon_url: null,
+ },
+ ],
+ })
+
+ const { rerender } = render()
+
+ expect(screen.getByText('App 1 -> /app/app-1/configuration')).toBeInTheDocument()
+ expect(screen.getByText('App 2 -> /app/app-2/workflow')).toBeInTheDocument()
+
+ mockAppDetail = { id: 'app-1', name: 'Updated App Name' }
+ rerender()
+
+ await waitFor(() => {
+ expect(screen.getByText('Updated App Name -> /app/app-1/configuration')).toBeInTheDocument()
+ })
+ })
+
+ it('should open and close create app modal, then refetch', async () => {
+ const user = userEvent.setup()
+ const { refetch } = setupDefaultMocks()
+ render()
+
+ await user.click(screen.getByTestId('create-blank'))
+ expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
+
+ await user.click(screen.getByTestId('create-app-modal'))
+ await waitFor(() => {
+ expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
+ expect(refetch).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should open and close template modal, then refetch', async () => {
+ const user = userEvent.setup()
+ const { refetch } = setupDefaultMocks()
+ render()
+
+ await user.click(screen.getByTestId('create-template'))
+ expect(screen.getByTestId('create-app-template-dialog')).toBeInTheDocument()
+
+ await user.click(screen.getByTestId('create-app-template-dialog'))
+ await waitFor(() => {
+ expect(screen.queryByTestId('create-app-template-dialog')).not.toBeInTheDocument()
+ expect(refetch).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should open and close DSL modal, then refetch', async () => {
+ const user = userEvent.setup()
+ const { refetch } = setupDefaultMocks()
+ render()
+
+ await user.click(screen.getByTestId('create-dsl'))
+ expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
+
+ await user.click(screen.getByTestId('create-from-dsl-modal'))
+ await waitFor(() => {
+ expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument()
+ expect(refetch).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should load more when user clicks load more and more data is available', async () => {
+ const user = userEvent.setup()
+ const { fetchNextPage } = setupDefaultMocks({ hasNextPage: true })
+ render()
+
+ await user.click(screen.getByTestId('load-more'))
+ expect(fetchNextPage).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not load more when user clicks load more and no data is available', async () => {
+ const user = userEvent.setup()
+ const { fetchNextPage } = setupDefaultMocks({ hasNextPage: false })
+ render()
+
+ await user.click(screen.getByTestId('load-more'))
+ expect(fetchNextPage).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/header/app-selector/index.spec.tsx b/web/app/components/header/app-selector/index.spec.tsx
new file mode 100644
index 0000000000..f301de4580
--- /dev/null
+++ b/web/app/components/header/app-selector/index.spec.tsx
@@ -0,0 +1,171 @@
+import type { AppDetailResponse } from '@/models/app'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { useRouter } from 'next/navigation'
+import { vi } from 'vitest'
+import { useAppContext } from '@/context/app-context'
+import AppSelector from './index'
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useRouter: vi.fn(),
+}))
+
+// Mock app context
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+// Mock CreateAppDialog to avoid complex dependencies
+vi.mock('@/app/components/app/create-app-dialog', () => ({
+ default: ({ show, onClose }: { show: boolean, onClose: () => void }) => show
+ ? (
+
+
+
+ )
+ : null,
+}))
+
+describe('AppSelector Component', () => {
+ const mockPush = vi.fn()
+ const mockAppItems = [
+ { id: '1', name: 'App 1' },
+ { id: '2', name: 'App 2' },
+ ] as unknown as AppDetailResponse[]
+ const mockCurApp = mockAppItems[0]
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useRouter).mockReturnValue({
+ push: mockPush,
+ } as unknown as ReturnType)
+ vi.mocked(useAppContext).mockReturnValue({
+ isCurrentWorkspaceEditor: true,
+ } as unknown as ReturnType)
+ })
+
+ describe('Rendering', () => {
+ it('should render current app name', () => {
+ render()
+ expect(screen.getByText('App 1')).toBeInTheDocument()
+ })
+ })
+
+ describe('Interactions', () => {
+ it('should open menu and show app items', async () => {
+ render()
+
+ const button = screen.getByRole('button', { name: /App 1/i })
+ await act(async () => {
+ fireEvent.click(button)
+ })
+
+ expect(screen.getByText('App 2')).toBeInTheDocument()
+ })
+
+ it('should navigate to configuration when an app is clicked and user is editor', async () => {
+ render()
+
+ const button = screen.getByRole('button', { name: /App 1/i })
+ await act(async () => {
+ fireEvent.click(button)
+ })
+
+ const app2Item = screen.getByText('App 2')
+ await act(async () => {
+ fireEvent.click(app2Item)
+ })
+
+ expect(mockPush).toHaveBeenCalledWith('/app/2/configuration')
+ })
+
+ it('should navigate to overview when an app is clicked and user is not editor', async () => {
+ vi.mocked(useAppContext).mockReturnValue({
+ isCurrentWorkspaceEditor: false,
+ } as unknown as ReturnType)
+
+ render()
+
+ const button = screen.getByRole('button', { name: /App 1/i })
+ await act(async () => {
+ fireEvent.click(button)
+ })
+
+ const app2Item = screen.getByText('App 2')
+ await act(async () => {
+ fireEvent.click(app2Item)
+ })
+
+ expect(mockPush).toHaveBeenCalledWith('/app/2/overview')
+ })
+ })
+
+ describe('New App Dialog', () => {
+ it('should show "New App" button for editor and open dialog', async () => {
+ render()
+
+ const button = screen.getByRole('button', { name: /App 1/i })
+ await act(async () => {
+ fireEvent.click(button)
+ })
+
+ const newAppBtn = screen.getByText('common.menus.newApp')
+ await act(async () => {
+ fireEvent.click(newAppBtn)
+ })
+
+ expect(screen.getByTestId('create-app-dialog')).toBeInTheDocument()
+ })
+
+ it('should not show "New App" button for non-editor', async () => {
+ vi.mocked(useAppContext).mockReturnValue({
+ isCurrentWorkspaceEditor: false,
+ } as unknown as ReturnType)
+
+ render()
+
+ const button = screen.getByRole('button', { name: /App 1/i })
+ await act(async () => {
+ fireEvent.click(button)
+ })
+
+ expect(screen.queryByText('common.menus.newApp')).not.toBeInTheDocument()
+ })
+
+ it('should close dialog when onClose is called', async () => {
+ render()
+
+ const button = screen.getByRole('button', { name: /App 1/i })
+ await act(async () => {
+ fireEvent.click(button)
+ })
+
+ const newAppBtn = screen.getByText('common.menus.newApp')
+ await act(async () => {
+ fireEvent.click(newAppBtn)
+ })
+
+ const closeBtn = screen.getByText('Close')
+ await act(async () => {
+ fireEvent.click(closeBtn)
+ })
+
+ expect(screen.queryByTestId('create-app-dialog')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render nothing in menu if appItems is empty', async () => {
+ render()
+
+ const button = screen.getByRole('button', { name: /App 1/i })
+ await act(async () => {
+ fireEvent.click(button)
+ })
+
+ expect(screen.queryByText('App 2')).not.toBeInTheDocument()
+ // "New App" should still be there if editor
+ expect(screen.getByText('common.menus.newApp')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/header/dataset-nav/index.spec.tsx b/web/app/components/header/dataset-nav/index.spec.tsx
new file mode 100644
index 0000000000..8c1b5952a7
--- /dev/null
+++ b/web/app/components/header/dataset-nav/index.spec.tsx
@@ -0,0 +1,268 @@
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
+import {
+ useParams,
+ useRouter,
+ useSelectedLayoutSegment,
+} from 'next/navigation'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAppContext } from '@/context/app-context'
+import {
+ useDatasetDetail,
+ useDatasetList,
+} from '@/service/knowledge/use-dataset'
+import DatasetNav from './index'
+
+vi.mock('next/navigation', () => ({
+ useParams: vi.fn(),
+ useRouter: vi.fn(),
+ useSelectedLayoutSegment: vi.fn(),
+}))
+
+vi.mock('@/service/knowledge/use-dataset', () => ({
+ useDatasetDetail: vi.fn(),
+ useDatasetList: vi.fn(),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+vi.mock('@remixicon/react', () => ({
+ RiBook2Fill: () => ,
+ RiBook2Line: () => ,
+ RiArrowDownSLine: () => ,
+ RiArrowRightSLine: () => ,
+ RiAddLine: () => ,
+}))
+
+vi.mock('@/app/components/base/loading', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/app/type-selector', () => ({
+ AppTypeIcon: () => ,
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
+ ArrowNarrowLeft: () => ,
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/line/files', () => ({
+ FileArrow01: () => ,
+ FilePlus01: () => ,
+ FilePlus02: () => ,
+}))
+
+describe('DatasetNav', () => {
+ const mockPush = vi.fn()
+ const mockFetchNextPage = vi.fn()
+
+ const mockDataset = {
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ runtime_mode: 'general',
+ icon_info: {
+ icon: 'book',
+ icon_type: 'image',
+ icon_background: '#fff',
+ icon_url: '/url',
+ },
+ provider: 'vendor',
+ }
+
+ const mockDatasetList = {
+ pages: [
+ {
+ data: [
+ mockDataset,
+ {
+ id: 'dataset-2',
+ name: 'Pipeline Dataset',
+ runtime_mode: 'rag_pipeline',
+ is_published: false,
+ icon_info: { icon: 'pipeline' },
+ provider: 'vendor',
+ },
+ {
+ id: 'dataset-3',
+ name: 'External Dataset',
+ runtime_mode: 'general',
+ icon_info: { icon: 'external' },
+ provider: 'external',
+ },
+ {
+ id: 'dataset-4',
+ name: 'Published Pipeline',
+ runtime_mode: 'rag_pipeline',
+ is_published: true,
+ icon_info: { icon: 'pipeline' },
+ provider: 'vendor',
+ },
+ ],
+ },
+ ],
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useRouter).mockReturnValue({
+ push: mockPush,
+ } as unknown as ReturnType)
+ vi.mocked(useParams).mockReturnValue({ datasetId: 'dataset-1' })
+ vi.mocked(useSelectedLayoutSegment).mockReturnValue('datasets')
+ vi.mocked(useDatasetDetail).mockReturnValue({
+ data: mockDataset,
+ } as unknown as ReturnType)
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: mockDatasetList,
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: true,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+ vi.mocked(useAppContext).mockReturnValue({
+ isCurrentWorkspaceEditor: true,
+ } as unknown as ReturnType)
+ })
+
+ describe('Rendering', () => {
+ it('should render the navigation component', () => {
+ render()
+ expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
+ })
+
+ it('should render without current dataset correctly', () => {
+ vi.mocked(useDatasetDetail).mockReturnValue({
+ data: undefined,
+ } as unknown as ReturnType)
+ render()
+ expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
+ })
+ })
+
+ describe('Navigation Items logic', () => {
+ it('should generate correct links for different dataset types', () => {
+ render()
+
+ const selector = screen.getByRole('button', { name: /Test Dataset/i })
+ fireEvent.click(selector)
+
+ const menu = screen.getByRole('menu')
+ expect(within(menu).getByText('Test Dataset')).toBeInTheDocument()
+ expect(within(menu).getByText('Pipeline Dataset')).toBeInTheDocument()
+ expect(within(menu).getByText('External Dataset')).toBeInTheDocument()
+ })
+
+ it('should navigate to correct link when an item is clicked', () => {
+ render()
+ const selector = screen.getByRole('button', { name: /Test Dataset/i })
+ fireEvent.click(selector)
+
+ const menu = screen.getByRole('menu')
+ const pipelineItem = within(menu).getByText('Pipeline Dataset')
+ fireEvent.click(pipelineItem)
+
+ // dataset-2 is rag_pipeline and not published -> /datasets/dataset-2/pipeline
+ expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-2/pipeline')
+
+ fireEvent.click(selector)
+ const menu2 = screen.getByRole('menu')
+ const externalItem = within(menu2).getByText('External Dataset')
+ fireEvent.click(externalItem)
+ // dataset-3 is provider external -> /datasets/dataset-3/hitTesting
+ expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-3/hitTesting')
+
+ fireEvent.click(selector)
+ const menu3 = screen.getByRole('menu')
+ const publishedItem = within(menu3).getByText('Published Pipeline')
+ fireEvent.click(publishedItem)
+ // dataset-4 is rag_pipeline and published -> /datasets/dataset-4/documents
+ expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-4/documents')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call router.push with correct path when creating a general dataset', () => {
+ render()
+
+ const selector = screen.getByRole('button', { name: /Test Dataset/i })
+ fireEvent.click(selector)
+
+ const menu = screen.getByRole('menu')
+ const createBtn = within(menu).getByText('common.menus.newDataset')
+ fireEvent.click(createBtn)
+
+ expect(mockPush).toHaveBeenCalledWith('/datasets/create')
+ })
+
+ it('should call router.push with correct path when creating a pipeline dataset', () => {
+ vi.mocked(useDatasetDetail).mockReturnValue({
+ data: { ...mockDataset, runtime_mode: 'rag_pipeline' },
+ } as unknown as ReturnType)
+
+ render()
+ const selector = screen.getByRole('button', { name: /Test Dataset/i })
+ fireEvent.click(selector)
+
+ const menu = screen.getByRole('menu')
+ const createBtn = within(menu).getByText('common.menus.newDataset')
+ fireEvent.click(createBtn)
+
+ expect(mockPush).toHaveBeenCalledWith('/datasets/create-from-pipeline')
+ })
+
+ it('should trigger fetchNextPage when loading more', () => {
+ vi.useFakeTimers()
+ render()
+ const selector = screen.getByRole('button', { name: /Test Dataset/i })
+ fireEvent.click(selector)
+
+ const menu = screen.getByRole('menu')
+ const scrollContainer = menu.querySelector('.overflow-auto')
+ if (scrollContainer) {
+ Object.defineProperty(scrollContainer, 'scrollHeight', { value: 1000 })
+ Object.defineProperty(scrollContainer, 'clientHeight', { value: 500 })
+ Object.defineProperty(scrollContainer, 'scrollTop', { value: 500 })
+
+ fireEvent.scroll(scrollContainer)
+ act(() => {
+ vi.advanceTimersByTime(100)
+ })
+ expect(mockFetchNextPage).toHaveBeenCalled()
+ }
+ vi.useRealTimers()
+ })
+
+ it('should not trigger fetchNextPage if hasNextPage is false', () => {
+ vi.useFakeTimers()
+ vi.mocked(useDatasetList).mockReturnValue({
+ data: mockDatasetList,
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+
+ render()
+ const selector = screen.getByRole('button', { name: /Test Dataset/i })
+ fireEvent.click(selector)
+
+ const menu = screen.getByRole('menu')
+ const scrollContainer = menu.querySelector('.overflow-auto')
+ if (scrollContainer) {
+ Object.defineProperty(scrollContainer, 'scrollHeight', { value: 1000 })
+ Object.defineProperty(scrollContainer, 'clientHeight', { value: 500 })
+ Object.defineProperty(scrollContainer, 'scrollTop', { value: 500 })
+
+ fireEvent.scroll(scrollContainer)
+ act(() => {
+ vi.advanceTimersByTime(100)
+ })
+ expect(mockFetchNextPage).not.toHaveBeenCalled()
+ }
+ vi.useRealTimers()
+ })
+ })
+})
diff --git a/web/app/components/header/env-nav/index.spec.tsx b/web/app/components/header/env-nav/index.spec.tsx
new file mode 100644
index 0000000000..2b13af1016
--- /dev/null
+++ b/web/app/components/header/env-nav/index.spec.tsx
@@ -0,0 +1,52 @@
+import type { AppContextValue } from '@/context/app-context'
+import { render, screen } from '@testing-library/react'
+import { vi } from 'vitest'
+import { useAppContext } from '@/context/app-context'
+import EnvNav from './index'
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+describe('EnvNav', () => {
+ const mockUseAppContext = vi.mocked(useAppContext)
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render null when environment is PRODUCTION', () => {
+ mockUseAppContext.mockReturnValue({
+ langGeniusVersionInfo: {
+ current_env: 'PRODUCTION',
+ },
+ } as unknown as AppContextValue)
+
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render TESTING tag and icon when environment is TESTING', () => {
+ mockUseAppContext.mockReturnValue({
+ langGeniusVersionInfo: {
+ current_env: 'TESTING',
+ },
+ } as unknown as AppContextValue)
+
+ render()
+ expect(screen.getByText('common.environment.testing')).toBeInTheDocument()
+ })
+
+ it('should render DEVELOPMENT tag and icon when environment is DEVELOPMENT', () => {
+ mockUseAppContext.mockReturnValue({
+ langGeniusVersionInfo: {
+ current_env: 'DEVELOPMENT',
+ },
+ } as unknown as AppContextValue)
+
+ render()
+ expect(
+ screen.getByText('common.environment.development'),
+ ).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/header/explore-nav/index.spec.tsx b/web/app/components/header/explore-nav/index.spec.tsx
new file mode 100644
index 0000000000..65a3f88f5e
--- /dev/null
+++ b/web/app/components/header/explore-nav/index.spec.tsx
@@ -0,0 +1,45 @@
+import type { Mock } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { useSelectedLayoutSegment } from 'next/navigation'
+import ExploreNav from './index'
+
+vi.mock('next/navigation', () => ({
+ useSelectedLayoutSegment: vi.fn(),
+}))
+
+describe('ExploreNav', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render correctly when not active', () => {
+ (useSelectedLayoutSegment as Mock).mockReturnValue('other')
+ render()
+
+ const link = screen.getByRole('link')
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveAttribute('href', '/explore/apps')
+ expect(link).toHaveClass('text-components-main-nav-nav-button-text')
+ expect(link).not.toHaveClass('bg-components-main-nav-nav-button-bg-active')
+ expect(screen.getByText('common.menus.explore')).toBeInTheDocument()
+ })
+
+ it('should render correctly when active', () => {
+ (useSelectedLayoutSegment as Mock).mockReturnValue('explore')
+ render()
+
+ const link = screen.getByRole('link')
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveClass('bg-components-main-nav-nav-button-bg-active')
+ expect(link).toHaveClass('text-components-main-nav-nav-button-text-active')
+ expect(screen.getByText('common.menus.explore')).toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ (useSelectedLayoutSegment as Mock).mockReturnValue('other')
+ render()
+
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('custom-test-class')
+ })
+})
diff --git a/web/app/components/header/header-wrapper.spec.tsx b/web/app/components/header/header-wrapper.spec.tsx
new file mode 100644
index 0000000000..80ddb14965
--- /dev/null
+++ b/web/app/components/header/header-wrapper.spec.tsx
@@ -0,0 +1,112 @@
+import { act, render, screen } from '@testing-library/react'
+import { usePathname } from 'next/navigation'
+import { vi } from 'vitest'
+import { useEventEmitterContextContext } from '@/context/event-emitter'
+import HeaderWrapper from './header-wrapper'
+
+vi.mock('next/navigation', () => ({
+ usePathname: vi.fn(),
+}))
+
+vi.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: vi.fn(),
+}))
+
+describe('HeaderWrapper', () => {
+ type CanvasEvent = { type: string, payload: boolean }
+ let subscriptionCallback: ((event: CanvasEvent) => void) | null = null
+ const mockUseSubscription = vi.fn<(callback: (event: CanvasEvent) => void) => void>((callback) => {
+ subscriptionCallback = callback
+ })
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ subscriptionCallback = null
+ vi.mocked(usePathname).mockReturnValue('/test')
+ vi.mocked(useEventEmitterContextContext).mockReturnValue({
+ eventEmitter: { useSubscription: mockUseSubscription },
+ } as never)
+ })
+
+ it('should render children correctly', () => {
+ render(
+
+ Test Child
+ ,
+ )
+ expect(screen.getByTestId('child')).toBeInTheDocument()
+ expect(screen.getByText('Test Child')).toBeInTheDocument()
+ })
+
+ it('should keep children mounted when workflow maximize events are emitted', () => {
+ vi.mocked(usePathname).mockReturnValue('/some/path/workflow')
+ render(
+
+ Workflow Content
+ ,
+ )
+
+ act(() => {
+ subscriptionCallback?.({ type: 'workflow-canvas-maximize', payload: true })
+ subscriptionCallback?.({ type: 'workflow-canvas-maximize', payload: false })
+ })
+
+ expect(screen.getByText('Workflow Content')).toBeInTheDocument()
+ })
+
+ it('should keep children mounted on pipeline routes when maximize is enabled from storage', () => {
+ vi.mocked(usePathname).mockReturnValue('/some/path/pipeline')
+ localStorage.setItem('workflow-canvas-maximize', 'true')
+
+ render(
+
+ Pipeline Content
+ ,
+ )
+
+ expect(screen.getByText('Pipeline Content')).toBeInTheDocument()
+ })
+
+ it('should keep children mounted on non-canvas routes when maximize is enabled from storage', () => {
+ vi.mocked(usePathname).mockReturnValue('/apps')
+ localStorage.setItem('workflow-canvas-maximize', 'true')
+
+ render(
+
+ App Content
+ ,
+ )
+
+ expect(screen.getByText('App Content')).toBeInTheDocument()
+ })
+
+ it('should keep children mounted when unrelated events are emitted', () => {
+ vi.mocked(usePathname).mockReturnValue('/some/path/workflow')
+ render(
+
+ Workflow Content
+ ,
+ )
+
+ act(() => {
+ subscriptionCallback?.({ type: 'other-event', payload: true })
+ })
+
+ expect(screen.getByText('Workflow Content')).toBeInTheDocument()
+ })
+
+ it('should render children when eventEmitter is unavailable', () => {
+ vi.mocked(useEventEmitterContextContext).mockReturnValue({
+ eventEmitter: undefined,
+ } as never)
+
+ render(
+
+ Content Without Emitter
+ ,
+ )
+
+ expect(screen.getByText('Content Without Emitter')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/header/index.spec.tsx b/web/app/components/header/index.spec.tsx
new file mode 100644
index 0000000000..2d5ce50fb8
--- /dev/null
+++ b/web/app/components/header/index.spec.tsx
@@ -0,0 +1,191 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { vi } from 'vitest'
+import Header from './index'
+
+function createMockComponent(testId: string) {
+ return () =>
+}
+
+vi.mock('@/app/components/base/logo/dify-logo', () => ({
+ default: createMockComponent('dify-logo'),
+}))
+
+vi.mock('@/app/components/header/account-dropdown/workplace-selector', () => ({
+ default: createMockComponent('workplace-selector'),
+}))
+
+vi.mock('@/app/components/header/account-dropdown', () => ({
+ default: createMockComponent('account-dropdown'),
+}))
+
+vi.mock('@/app/components/header/app-nav', () => ({
+ default: createMockComponent('app-nav'),
+}))
+
+vi.mock('@/app/components/header/dataset-nav', () => ({
+ default: createMockComponent('dataset-nav'),
+}))
+
+vi.mock('@/app/components/header/env-nav', () => ({
+ default: createMockComponent('env-nav'),
+}))
+
+vi.mock('@/app/components/header/explore-nav', () => ({
+ default: createMockComponent('explore-nav'),
+}))
+
+vi.mock('@/app/components/header/license-env', () => ({
+ default: createMockComponent('license-nav'),
+}))
+
+vi.mock('@/app/components/header/plugins-nav', () => ({
+ default: createMockComponent('plugins-nav'),
+}))
+
+vi.mock('@/app/components/header/tools-nav', () => ({
+ default: createMockComponent('tools-nav'),
+}))
+
+vi.mock('@/app/components/header/plan-badge', () => ({
+ default: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => (
+
+ ),
+}))
+
+vi.mock('@/context/workspace-context', () => ({
+ WorkspaceProvider: ({ children }: { children?: React.ReactNode }) => children,
+}))
+
+vi.mock('next/link', () => ({
+ default: ({ children, href }: { children?: React.ReactNode, href?: string }) => {children},
+}))
+
+let mockIsWorkspaceEditor = false
+let mockIsDatasetOperator = false
+let mockMedia = 'desktop'
+let mockEnableBilling = false
+let mockPlanType = 'sandbox'
+let mockBrandingEnabled = false
+let mockBrandingTitle: string | null = null
+let mockBrandingLogo: string | null = null
+const mockSetShowPricingModal = vi.fn()
+const mockSetShowAccountSettingModal = vi.fn()
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ isCurrentWorkspaceEditor: mockIsWorkspaceEditor,
+ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator,
+ }),
+}))
+
+vi.mock('@/hooks/use-breakpoints', () => ({
+ default: () => mockMedia,
+ MediaType: { mobile: 'mobile', tablet: 'tablet', desktop: 'desktop' },
+}))
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ enableBilling: mockEnableBilling,
+ plan: { type: mockPlanType },
+ }),
+}))
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowPricingModal: mockSetShowPricingModal,
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ }),
+}))
+
+vi.mock('@/context/global-public-context', () => {
+ type SystemFeatures = { branding: { enabled: boolean, application_title: string | null, workspace_logo: string | null } }
+ return {
+ useGlobalPublicStore: (selector: (s: { systemFeatures: SystemFeatures }) => SystemFeatures) =>
+ selector({
+ systemFeatures: {
+ branding: {
+ enabled: mockBrandingEnabled,
+ application_title: mockBrandingTitle,
+ workspace_logo: mockBrandingLogo,
+ },
+ },
+ }),
+ }
+})
+
+describe('Header', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsWorkspaceEditor = false
+ mockIsDatasetOperator = false
+ mockMedia = 'desktop'
+ mockEnableBilling = false
+ mockPlanType = 'sandbox'
+ mockBrandingEnabled = false
+ mockBrandingTitle = null
+ mockBrandingLogo = null
+ })
+
+ it('should render header with main nav components', () => {
+ render()
+
+ expect(screen.getByTestId('dify-logo')).toBeInTheDocument()
+ expect(screen.getByTestId('workplace-selector')).toBeInTheDocument()
+ expect(screen.getByTestId('app-nav')).toBeInTheDocument()
+ expect(screen.getByTestId('account-dropdown')).toBeInTheDocument()
+ })
+
+ it('should show license nav when billing disabled, plan badge when enabled', () => {
+ mockEnableBilling = false
+ const { rerender } = render()
+ expect(screen.getByTestId('license-nav')).toBeInTheDocument()
+ expect(screen.queryByTestId('plan-badge')).not.toBeInTheDocument()
+
+ mockEnableBilling = true
+ rerender()
+ expect(screen.queryByTestId('license-nav')).not.toBeInTheDocument()
+ expect(screen.getByTestId('plan-badge')).toBeInTheDocument()
+ })
+
+ it('should hide explore nav when user is dataset operator', () => {
+ mockIsDatasetOperator = true
+ render()
+
+ expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument()
+ expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
+ })
+
+ it('should call pricing modal for free plan, settings modal for paid plan', () => {
+ mockEnableBilling = true
+ mockPlanType = 'sandbox'
+ const { rerender } = render()
+
+ fireEvent.click(screen.getByTestId('plan-badge'))
+ expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
+
+ mockPlanType = 'professional'
+ rerender()
+ fireEvent.click(screen.getByTestId('plan-badge'))
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render mobile layout without env nav', () => {
+ mockMedia = 'mobile'
+ render()
+
+ expect(screen.getByTestId('dify-logo')).toBeInTheDocument()
+ expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
+ })
+
+ it('should render branded title and logo when branding is enabled', () => {
+ mockBrandingEnabled = true
+ mockBrandingTitle = 'Acme Workspace'
+ mockBrandingLogo = '/logo.png'
+
+ render()
+
+ expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
+ expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
+ expect(screen.queryByTestId('dify-logo')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/header/indicator/index.spec.tsx b/web/app/components/header/indicator/index.spec.tsx
new file mode 100644
index 0000000000..b5921d8fc0
--- /dev/null
+++ b/web/app/components/header/indicator/index.spec.tsx
@@ -0,0 +1,79 @@
+import { render, screen } from '@testing-library/react'
+import Indicator from './index'
+
+describe('Indicator', () => {
+ it('should render with default props', () => {
+ render()
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toBeInTheDocument()
+ expect(indicator).toHaveClass(
+ 'bg-components-badge-status-light-success-bg',
+ )
+ expect(indicator).toHaveClass(
+ 'border-components-badge-status-light-success-border-inner',
+ )
+ expect(indicator).toHaveClass('shadow-status-indicator-green-shadow')
+ })
+
+ it('should render with orange color', () => {
+ render()
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass(
+ 'bg-components-badge-status-light-warning-bg',
+ )
+ expect(indicator).toHaveClass(
+ 'border-components-badge-status-light-warning-border-inner',
+ )
+ expect(indicator).toHaveClass('shadow-status-indicator-warning-shadow')
+ })
+
+ it('should render with red color', () => {
+ render()
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass('bg-components-badge-status-light-error-bg')
+ expect(indicator).toHaveClass(
+ 'border-components-badge-status-light-error-border-inner',
+ )
+ expect(indicator).toHaveClass('shadow-status-indicator-red-shadow')
+ })
+
+ it('should render with blue color', () => {
+ render()
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass('bg-components-badge-status-light-normal-bg')
+ expect(indicator).toHaveClass(
+ 'border-components-badge-status-light-normal-border-inner',
+ )
+ expect(indicator).toHaveClass('shadow-status-indicator-blue-shadow')
+ })
+
+ it('should render with yellow color', () => {
+ render()
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass(
+ 'bg-components-badge-status-light-warning-bg',
+ )
+ expect(indicator).toHaveClass(
+ 'border-components-badge-status-light-warning-border-inner',
+ )
+ expect(indicator).toHaveClass('shadow-status-indicator-warning-shadow')
+ })
+
+ it('should render with gray color', () => {
+ render()
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass(
+ 'bg-components-badge-status-light-disabled-bg',
+ )
+ expect(indicator).toHaveClass(
+ 'border-components-badge-status-light-disabled-border-inner',
+ )
+ expect(indicator).toHaveClass('shadow-status-indicator-gray-shadow')
+ })
+
+ it('should apply custom className', () => {
+ render()
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass('custom-class')
+ })
+})
diff --git a/web/app/components/header/license-env/index.spec.tsx b/web/app/components/header/license-env/index.spec.tsx
new file mode 100644
index 0000000000..df3559909b
--- /dev/null
+++ b/web/app/components/header/license-env/index.spec.tsx
@@ -0,0 +1,92 @@
+import { render, screen } from '@testing-library/react'
+import dayjs from 'dayjs'
+import { useGlobalPublicStore } from '@/context/global-public-context'
+import { defaultSystemFeatures, LicenseStatus } from '@/types/feature'
+import LicenseNav from './index'
+
+describe('LicenseNav', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ const now = new Date('2024-01-01T12:00:00Z')
+ vi.setSystemTime(now)
+ useGlobalPublicStore.setState({
+ systemFeatures: defaultSystemFeatures,
+ })
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should render null when license status is NONE', () => {
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should render Enterprise badge when license status is ACTIVE', () => {
+ useGlobalPublicStore.setState({
+ systemFeatures: {
+ ...defaultSystemFeatures,
+ license: {
+ status: LicenseStatus.ACTIVE,
+ expired_at: null,
+ },
+ },
+ })
+
+ render()
+ expect(screen.getByText('Enterprise')).toBeInTheDocument()
+ })
+
+ it('should render singular expiring message when license expires in 0 days', () => {
+ const expiredAt = dayjs().add(2, 'hours').toISOString()
+ useGlobalPublicStore.setState({
+ systemFeatures: {
+ ...defaultSystemFeatures,
+ license: {
+ status: LicenseStatus.EXPIRING,
+ expired_at: expiredAt,
+ },
+ },
+ })
+
+ render()
+ expect(screen.getByText(/license\.expiring/)).toBeInTheDocument()
+ expect(screen.getByText(/count":0/)).toBeInTheDocument()
+ })
+
+ it('should render singular expiring message when license expires in 1 day', () => {
+ const tomorrow = dayjs().add(1, 'day').add(1, 'hour').toISOString()
+ useGlobalPublicStore.setState({
+ systemFeatures: {
+ ...defaultSystemFeatures,
+ license: {
+ status: LicenseStatus.EXPIRING,
+ expired_at: tomorrow,
+ },
+ },
+ })
+
+ render()
+ expect(screen.getByText(/license\.expiring/)).toBeInTheDocument()
+ expect(screen.getByText(/count":1/)).toBeInTheDocument()
+ })
+
+ it('should render plural expiring message when license expires in 5 days', () => {
+ const fiveDaysLater = dayjs().add(5, 'day').add(1, 'hour').toISOString()
+ useGlobalPublicStore.setState({
+ systemFeatures: {
+ ...defaultSystemFeatures,
+ license: {
+ status: LicenseStatus.EXPIRING,
+ expired_at: fiveDaysLater,
+ },
+ },
+ })
+
+ render()
+ expect(screen.getByText(/license\.expiring_plural/)).toBeInTheDocument()
+ expect(screen.getByText(/count":5/)).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/header/nav/index.spec.tsx b/web/app/components/header/nav/index.spec.tsx
new file mode 100644
index 0000000000..ab530a4a86
--- /dev/null
+++ b/web/app/components/header/nav/index.spec.tsx
@@ -0,0 +1,376 @@
+import type { NavItem } from './nav-selector'
+import type { AppContextValue } from '@/context/app-context'
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react'
+import { useRouter, useSelectedLayoutSegment } from 'next/navigation'
+import * as React from 'react'
+import { vi } from 'vitest'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import { useAppContext } from '@/context/app-context'
+import { AppModeEnum } from '@/types/app'
+import Nav from './index'
+
+vi.mock('@headlessui/react', () => {
+ type MenuContextValue = { open: boolean, setOpen: (open: boolean) => void }
+ const MenuContext = React.createContext(null)
+
+ const Menu = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => {
+ const [open, setOpen] = React.useState(false)
+ const value = React.useMemo(() => ({ open, setOpen }), [open])
+ return (
+
+ {typeof children === 'function' ? children({ open }) : children}
+
+ )
+ }
+
+ const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => {
+ const context = React.useContext(MenuContext)
+ const handleClick = () => {
+ context?.setOpen(!context.open)
+ onClick?.()
+ }
+ return (
+
+ )
+ }
+
+ const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => {
+ const context = React.useContext(MenuContext)
+ if (!context?.open)
+ return null
+ return (
+
+ {children}
+
+ )
+ }
+
+ const MenuItem = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => (
+
+ {children}
+
+ )
+
+ return {
+ Menu,
+ MenuButton,
+ MenuItems,
+ MenuItem,
+ Transition: ({ show = true, children }: { show?: boolean, children: React.ReactNode }) => (show ? <>{children}> : null),
+ }
+})
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useSelectedLayoutSegment: vi.fn(),
+ useRouter: vi.fn(),
+}))
+
+// Mock app store
+vi.mock('@/app/components/app/store', () => ({
+ useStore: vi.fn(),
+}))
+
+// Mock app context
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+describe('Nav Component', () => {
+ const mockSetAppDetail = vi.fn()
+ const mockOnCreate = vi.fn()
+ const mockOnLoadMore = vi.fn()
+ const mockPush = vi.fn()
+
+ const navigationItems: NavItem[] = [
+ {
+ id: '1',
+ name: 'Item 1',
+ link: '/item1',
+ icon_type: 'image',
+ icon: 'icon1',
+ icon_background: '#fff',
+ icon_url: '/url1',
+ mode: AppModeEnum.CHAT,
+ },
+ {
+ id: '2',
+ name: 'Item 2',
+ link: '/item2',
+ icon_type: 'image',
+ icon: 'icon2',
+ icon_background: '#000',
+ icon_url: '/url2',
+ },
+ ]
+
+ const defaultProps = {
+ icon: Icon,
+ activeIcon: Active Icon,
+ text: 'Nav Text',
+ activeSegment: 'explore',
+ link: '/explore',
+ isApp: false,
+ navigationItems,
+ createText: 'Create New',
+ onCreate: mockOnCreate,
+ onLoadMore: mockOnLoadMore,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useSelectedLayoutSegment).mockReturnValue('explore')
+ vi.mocked(useAppStore).mockReturnValue(mockSetAppDetail)
+ vi.mocked(useAppContext).mockReturnValue({
+ isCurrentWorkspaceEditor: true,
+ } as unknown as AppContextValue)
+ vi.mocked(useRouter).mockReturnValue({
+ push: mockPush,
+ } as unknown as ReturnType)
+ })
+
+ describe('Rendering', () => {
+ it('should render correctly when activated', () => {
+ render()
+ expect(screen.getByText('Nav Text')).toBeInTheDocument()
+ expect(screen.getByTestId('active-icon')).toBeInTheDocument()
+ })
+
+ it('should render correctly when not activated', () => {
+ vi.mocked(useSelectedLayoutSegment).mockReturnValue('other')
+ render()
+ expect(screen.getByTestId('default-icon')).toBeInTheDocument()
+ })
+
+ it('should handle array activeSegment', () => {
+ render()
+ expect(screen.getByTestId('active-icon')).toBeInTheDocument()
+ })
+
+ it('should not show hover background if not activated', () => {
+ vi.mocked(useSelectedLayoutSegment).mockReturnValue('other')
+ const { container } = render()
+ const navDiv = container.firstChild as HTMLElement
+ expect(navDiv.className).toContain(
+ 'hover:bg-components-main-nav-nav-button-bg-hover',
+ )
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call setAppDetail when clicked', () => {
+ render()
+ const link = screen.getByRole('link')
+ fireEvent.click(link.firstChild!)
+ expect(mockSetAppDetail).toHaveBeenCalled()
+ })
+
+ it('should not call setAppDetail when clicked with modifier keys', () => {
+ render()
+ const link = screen.getByRole('link')
+ fireEvent.click(link.firstChild!, { metaKey: true })
+ expect(mockSetAppDetail).not.toHaveBeenCalled()
+ })
+
+ it('should show ArrowNarrowLeft on hover when curNav is provided and activated', () => {
+ const curNav = navigationItems[0]
+ render()
+
+ const navItem = screen.getByText('Nav Text').parentElement!
+ fireEvent.mouseEnter(navItem)
+
+ expect(screen.queryByTestId('active-icon')).not.toBeInTheDocument()
+
+ fireEvent.mouseLeave(navItem)
+ expect(screen.getByTestId('active-icon')).toBeInTheDocument()
+ })
+ })
+
+ describe('NavSelector', () => {
+ const curNav = navigationItems[0]
+
+ it('should render NavSelector when activated and curNav is provided', () => {
+ render()
+ expect(screen.getByText('/')).toBeInTheDocument()
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
+ })
+
+ it('should open menu and show items when clicked', async () => {
+ render()
+ const selectorButton = screen.getByRole('button', { name: /Item 1/i })
+
+ await act(async () => {
+ fireEvent.click(selectorButton)
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Item 2')).toBeInTheDocument()
+ })
+ })
+
+ it('should navigate when an item is selected', async () => {
+ render()
+ const selectorButton = screen.getByRole('button', { name: /Item 1/i })
+
+ await act(async () => {
+ fireEvent.click(selectorButton)
+ })
+
+ const item2 = await screen.findByText('Item 2')
+ await act(async () => {
+ fireEvent.click(item2)
+ })
+
+ expect(mockSetAppDetail).toHaveBeenCalled()
+ expect(mockPush).toHaveBeenCalledWith('/item2')
+ })
+
+ it('should not navigate if selecting current nav item', async () => {
+ render()
+ const selectorButton = screen.getByRole('button', { name: /Item 1/i })
+
+ await act(async () => {
+ fireEvent.click(selectorButton)
+ })
+
+ const listItems = await screen.findAllByText('Item 1')
+ const listItem = listItems.find(el => el.closest('[role="menuitem"]'))
+
+ if (listItem) {
+ await act(async () => {
+ fireEvent.click(listItem)
+ })
+ }
+
+ expect(mockPush).not.toHaveBeenCalled()
+ })
+
+ it('should call onCreate when create button is clicked', async () => {
+ render()
+ const selectorButton = screen.getByRole('button', { name: /Item 1/i })
+
+ await act(async () => {
+ fireEvent.click(selectorButton)
+ })
+
+ const createButton = await screen.findByText('Create New')
+ await act(async () => {
+ fireEvent.click(createButton)
+ })
+
+ expect(mockOnCreate).toHaveBeenCalledWith('')
+ })
+
+ it('should show sub-menu and call onCreate with types when isApp is true', async () => {
+ render()
+ const selectorButton = screen.getByRole('button', { name: /Item 1/i })
+
+ await act(async () => {
+ fireEvent.click(selectorButton)
+ })
+
+ const createButton = await screen.findByText('Create New')
+ await act(async () => {
+ fireEvent.click(createButton)
+ })
+
+ const blankOption = await screen.findByText(
+ /app\.newApp\.startFromBlank/i,
+ )
+ await act(async () => {
+ fireEvent.click(blankOption)
+ })
+ expect(mockOnCreate).toHaveBeenCalledWith('blank')
+
+ const templateOption = await screen.findByText(
+ /app\.newApp\.startFromTemplate/i,
+ )
+ await act(async () => {
+ fireEvent.click(templateOption)
+ })
+ expect(mockOnCreate).toHaveBeenCalledWith('template')
+
+ const dslOption = await screen.findByText(/app\.importDSL/i)
+ await act(async () => {
+ fireEvent.click(dslOption)
+ })
+ expect(mockOnCreate).toHaveBeenCalledWith('dsl')
+ })
+
+ it('should not show create button if NOT an editor', async () => {
+ vi.mocked(useAppContext).mockReturnValue({
+ isCurrentWorkspaceEditor: false,
+ } as unknown as AppContextValue)
+ render()
+ const selectorButton = screen.getByRole('button', { name: /Item 1/i })
+
+ await act(async () => {
+ fireEvent.click(selectorButton)
+ })
+
+ await waitFor(() => {
+ expect(screen.queryByText('Create New')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should show loading state in selector when isLoadingMore is true', async () => {
+ render()
+ const selectorButton = screen.getByRole('button', { name: /Item 1/i })
+
+ await act(async () => {
+ fireEvent.click(selectorButton)
+ })
+
+ const status = await screen.findByRole('status')
+ expect(status).toBeInTheDocument()
+ })
+
+ it('should call onLoadMore when scrolling reaches bottom', async () => {
+ render()
+ const selectorButton = screen.getByRole('button', { name: /Item 1/i })
+
+ await act(async () => {
+ fireEvent.click(selectorButton)
+ })
+
+ const scrollContainer = await screen.findByRole('menu').then((menu) => {
+ const container = menu.querySelector('.overflow-auto')
+ if (!container)
+ throw new Error('Not found')
+ return container as HTMLElement
+ })
+
+ vi.useFakeTimers()
+
+ Object.defineProperty(scrollContainer, 'scrollHeight', {
+ value: 600,
+ configurable: true,
+ })
+ Object.defineProperty(scrollContainer, 'clientHeight', {
+ value: 150,
+ configurable: true,
+ })
+ Object.defineProperty(scrollContainer, 'scrollTop', {
+ value: 500,
+ configurable: true,
+ })
+
+ fireEvent.scroll(scrollContainer)
+
+ act(() => {
+ vi.runAllTimers()
+ })
+
+ expect(mockOnLoadMore).toHaveBeenCalled()
+ vi.useRealTimers()
+ })
+ })
+})
diff --git a/web/app/components/header/nav/nav-selector/index.spec.tsx b/web/app/components/header/nav/nav-selector/index.spec.tsx
new file mode 100644
index 0000000000..d613d4bf73
--- /dev/null
+++ b/web/app/components/header/nav/nav-selector/index.spec.tsx
@@ -0,0 +1,308 @@
+import type { INavSelectorProps, NavItem } from './index'
+import type { AppContextValue } from '@/context/app-context'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { useRouter } from 'next/navigation'
+import * as React from 'react'
+import { vi } from 'vitest'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import { useAppContext } from '@/context/app-context'
+import { AppModeEnum } from '@/types/app'
+import NavSelector from './index'
+
+vi.mock('@headlessui/react', () => {
+ type MenuContextValue = { open: boolean, setOpen: (open: boolean) => void }
+ const MenuContext = React.createContext(null)
+
+ const Menu = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => {
+ const [open, setOpen] = React.useState(false)
+ const value = React.useMemo(() => ({ open, setOpen }), [open])
+ return (
+
+ {typeof children === 'function' ? children({ open }) : children}
+
+ )
+ }
+
+ const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => {
+ const context = React.useContext(MenuContext)
+ const handleClick = () => {
+ context?.setOpen(!context.open)
+ onClick?.()
+ }
+ return (
+
+ )
+ }
+
+ const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => {
+ const context = React.useContext(MenuContext)
+ if (!context?.open)
+ return null
+ return (
+
+ {children}
+
+ )
+ }
+
+ const MenuItem = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => (
+
+ {children}
+
+ )
+
+ return {
+ Menu,
+ MenuButton,
+ MenuItems,
+ MenuItem,
+ Transition: ({ show = true, children }: { show?: boolean, children: React.ReactNode }) => (show ? <>{children}> : null),
+ }
+})
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useRouter: vi.fn(),
+}))
+
+// Mock app store
+vi.mock('@/app/components/app/store', () => ({
+ useStore: vi.fn(),
+}))
+
+// Mock app context
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+describe('NavSelector Component', () => {
+ const mockSetAppDetail = vi.fn()
+ const mockOnCreate = vi.fn()
+ const mockOnLoadMore = vi.fn()
+ const mockPush = vi.fn()
+
+ const navigationItems: NavItem[] = [
+ {
+ id: '1',
+ name: 'Item 1',
+ link: '/item1',
+ icon_type: 'image',
+ icon: 'icon1',
+ icon_background: '#fff',
+ icon_url: '/url1',
+ mode: AppModeEnum.CHAT,
+ },
+ {
+ id: '2',
+ name: 'Item 2',
+ link: '/item2',
+ icon_type: 'image',
+ icon: 'icon2',
+ icon_background: '#000',
+ icon_url: '/url2',
+ },
+ ]
+
+ const { link: _link, ...curNavWithoutLink } = navigationItems[0]
+
+ const defaultProps: INavSelectorProps = {
+ curNav: curNavWithoutLink,
+ navigationItems,
+ createText: 'Create New',
+ onCreate: mockOnCreate,
+ onLoadMore: mockOnLoadMore,
+ isApp: false,
+ isLoadingMore: false,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useAppStore).mockReturnValue(mockSetAppDetail)
+ vi.mocked(useAppContext).mockReturnValue({
+ isCurrentWorkspaceEditor: true,
+ } as unknown as AppContextValue)
+ vi.mocked(useRouter).mockReturnValue({
+ push: mockPush,
+ } as unknown as ReturnType)
+ })
+
+ describe('Rendering', () => {
+ it('should render current nav name', () => {
+ render()
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
+ })
+
+ it('should show loading indicator when isLoadingMore is true', async () => {
+ render()
+ const button = screen.getByRole('button')
+ await act(async () => {
+ fireEvent.click(button)
+ })
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+ })
+
+ describe('Interactions', () => {
+ it('should open menu and show items', async () => {
+ render()
+ const button = screen.getByRole('button')
+ await act(async () => {
+ fireEvent.click(button)
+ })
+ expect(screen.getByText('Item 2')).toBeInTheDocument()
+ })
+
+ it('should navigate and call setAppDetail when an item is clicked', async () => {
+ render()
+ const button = screen.getByRole('button')
+ await act(async () => {
+ fireEvent.click(button)
+ })
+ const item2 = screen.getByText('Item 2')
+ await act(async () => {
+ fireEvent.click(item2)
+ })
+ expect(mockSetAppDetail).toHaveBeenCalled()
+ expect(mockPush).toHaveBeenCalledWith('/item2')
+ })
+
+ it('should not navigate if current item is clicked', async () => {
+ render()
+ const button = screen.getByRole('button')
+ await act(async () => {
+ fireEvent.click(button)
+ })
+ const items = screen.getAllByText('Item 1')
+ const listItem = items.find(el => el.closest('[role="menuitem"]'))
+ if (listItem) {
+ await act(async () => {
+ fireEvent.click(listItem)
+ })
+ }
+ expect(mockPush).not.toHaveBeenCalled()
+ })
+
+ it('should call onCreate when create button is clicked (non-app mode)', async () => {
+ render()
+ const button = screen.getByRole('button')
+ await act(async () => {
+ fireEvent.click(button)
+ })
+ const createBtn = screen.getByText('Create New')
+ await act(async () => {
+ fireEvent.click(createBtn)
+ })
+ expect(mockOnCreate).toHaveBeenCalledWith('')
+ })
+
+ it('should show extended create menu in app mode', async () => {
+ render()
+ const button = screen.getByRole('button')
+ await act(async () => {
+ fireEvent.click(button)
+ })
+ const createBtn = screen.getByText('Create New')
+ await act(async () => {
+ fireEvent.click(createBtn)
+ })
+
+ const blank = await screen.findByText(/app\.newApp\.startFromBlank/i)
+ await act(async () => {
+ fireEvent.click(blank)
+ })
+ expect(mockOnCreate).toHaveBeenCalledWith('blank')
+
+ const template = await screen.findByText(/app\.newApp\.startFromTemplate/i)
+ await act(async () => {
+ fireEvent.click(template)
+ })
+ expect(mockOnCreate).toHaveBeenCalledWith('template')
+
+ const dsl = await screen.findByText(/app\.importDSL/i)
+ await act(async () => {
+ fireEvent.click(dsl)
+ })
+ expect(mockOnCreate).toHaveBeenCalledWith('dsl')
+ })
+
+ it('should not show create button for non-editors', async () => {
+ vi.mocked(useAppContext).mockReturnValue({
+ isCurrentWorkspaceEditor: false,
+ } as unknown as AppContextValue)
+ render()
+ const button = screen.getByRole('button')
+ await act(async () => {
+ fireEvent.click(button)
+ })
+ expect(screen.queryByText('Create New')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Scroll behavior', () => {
+ it('should call onLoadMore when scrolled to bottom', async () => {
+ render()
+ const button = screen.getByRole('button')
+ await act(async () => {
+ fireEvent.click(button)
+ })
+
+ const menu = screen.getByRole('menu')
+ const scrollable = menu.querySelector('.overflow-auto') as HTMLElement
+
+ vi.useFakeTimers()
+
+ // Trigger scroll
+ Object.defineProperty(scrollable, 'scrollHeight', {
+ value: 600,
+ configurable: true,
+ })
+ Object.defineProperty(scrollable, 'clientHeight', {
+ value: 150,
+ configurable: true,
+ })
+ Object.defineProperty(scrollable, 'scrollTop', {
+ value: 500,
+ configurable: true,
+ })
+
+ fireEvent.scroll(scrollable)
+
+ act(() => {
+ vi.runAllTimers()
+ })
+
+ expect(mockOnLoadMore).toHaveBeenCalled()
+
+ // Check that it's NOT called if not at bottom
+ mockOnLoadMore.mockClear()
+ Object.defineProperty(scrollable, 'scrollTop', {
+ value: 100,
+ configurable: true,
+ })
+ fireEvent.scroll(scrollable)
+ act(() => {
+ vi.runAllTimers()
+ })
+ expect(mockOnLoadMore).not.toHaveBeenCalled()
+
+ vi.useRealTimers()
+ })
+
+ it('should not throw if onLoadMore is undefined', async () => {
+ const { onLoadMore: _o, ...propsWithoutOnLoadMore } = defaultProps
+ render()
+ const button = screen.getByRole('button')
+ await act(async () => {
+ fireEvent.click(button)
+ })
+
+ const menu = screen.getByRole('menu')
+ const scrollable = menu.querySelector('.overflow-auto') as HTMLElement
+
+ fireEvent.scroll(scrollable)
+ // No error should be thrown
+ })
+ })
+})
diff --git a/web/app/components/header/plan-badge/index.spec.tsx b/web/app/components/header/plan-badge/index.spec.tsx
new file mode 100644
index 0000000000..80159588f5
--- /dev/null
+++ b/web/app/components/header/plan-badge/index.spec.tsx
@@ -0,0 +1,104 @@
+import type { Mock } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { vi } from 'vitest'
+import { createMockProviderContextValue } from '@/__mocks__/provider-context'
+import { useProviderContext } from '@/context/provider-context'
+import { Plan } from '../../billing/type'
+import PlanBadge from './index'
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: vi.fn(),
+ baseProviderContextValue: {},
+}))
+
+describe('PlanBadge', () => {
+ const mockUseProviderContext = useProviderContext as Mock
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should return null if isFetchedPlan is false', () => {
+ mockUseProviderContext.mockReturnValue(
+ createMockProviderContextValue({ isFetchedPlan: false }),
+ )
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render upgrade badge when plan is sandbox and sandboxAsUpgrade is true', () => {
+ mockUseProviderContext.mockReturnValue(
+ createMockProviderContextValue({ isFetchedPlan: true }),
+ )
+ render()
+ expect(
+ screen.getByText('billing.upgradeBtn.encourageShort'),
+ ).toBeInTheDocument()
+ })
+
+ it('should render sandbox badge when plan is sandbox and sandboxAsUpgrade is false', () => {
+ mockUseProviderContext.mockReturnValue(
+ createMockProviderContextValue({ isFetchedPlan: true }),
+ )
+ render()
+ expect(screen.getByText(Plan.sandbox)).toBeInTheDocument()
+ })
+
+ it('should render professional badge when plan is professional', () => {
+ mockUseProviderContext.mockReturnValue(
+ createMockProviderContextValue({ isFetchedPlan: true }),
+ )
+ render()
+ expect(screen.getByText('pro')).toBeInTheDocument()
+ })
+
+ it('should render graduation icon when isEducationWorkspace is true and plan is professional', () => {
+ mockUseProviderContext.mockReturnValue(
+ createMockProviderContextValue({
+ isFetchedPlan: true,
+ isEducationWorkspace: true,
+ }),
+ )
+ const { container } = render()
+
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ expect(screen.getByText('pro')).toBeInTheDocument()
+ })
+
+ it('should render team badge when plan is team', () => {
+ mockUseProviderContext.mockReturnValue(
+ createMockProviderContextValue({ isFetchedPlan: true }),
+ )
+ render()
+ expect(screen.getByText(Plan.team)).toBeInTheDocument()
+ })
+
+ it('should return null when plan is enterprise', () => {
+ mockUseProviderContext.mockReturnValue(
+ createMockProviderContextValue({ isFetchedPlan: true }),
+ )
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should trigger onClick when clicked', () => {
+ const handleClick = vi.fn()
+ mockUseProviderContext.mockReturnValue(
+ createMockProviderContextValue({ isFetchedPlan: true }),
+ )
+ render()
+ fireEvent.click(screen.getByText(Plan.team))
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle allowHover prop', () => {
+ mockUseProviderContext.mockReturnValue(
+ createMockProviderContextValue({ isFetchedPlan: true }),
+ )
+ const { container } = render(
+ ,
+ )
+
+ expect(container.firstChild).not.toBeNull()
+ })
+})
diff --git a/web/app/components/header/plugins-nav/index.spec.tsx b/web/app/components/header/plugins-nav/index.spec.tsx
new file mode 100644
index 0000000000..f76f579aa9
--- /dev/null
+++ b/web/app/components/header/plugins-nav/index.spec.tsx
@@ -0,0 +1,112 @@
+import type { Mock } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { useSelectedLayoutSegment } from 'next/navigation'
+import { usePluginTaskStatus } from '@/app/components/plugins/plugin-page/plugin-tasks/hooks'
+
+import PluginsNav from './index'
+
+vi.mock('next/navigation', () => ({
+ useSelectedLayoutSegment: vi.fn(),
+}))
+
+vi.mock('@/app/components/plugins/plugin-page/plugin-tasks/hooks', () => ({
+ usePluginTaskStatus: vi.fn(),
+}))
+
+describe('PluginsNav', () => {
+ const mockUseSelectedLayoutSegment = useSelectedLayoutSegment as Mock
+ const mockUsePluginTaskStatus = usePluginTaskStatus as Mock
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockUseSelectedLayoutSegment.mockReturnValue(null)
+ mockUsePluginTaskStatus.mockReturnValue({
+ isInstalling: false,
+ isInstallingWithError: false,
+ isFailed: false,
+ })
+ })
+
+ it('renders correctly (Default)', () => {
+ render()
+
+ const linkElement = screen.getByRole('link')
+ expect(linkElement).toHaveAttribute('href', '/plugins')
+ expect(screen.getByText('common.menus.plugins')).toBeInTheDocument()
+
+ const svg = linkElement.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+
+ expect(screen.queryByTestId('status-indicator')).not.toBeInTheDocument()
+ })
+
+ describe('Active State', () => {
+ it('should have active styling when segment is "plugins"', () => {
+ mockUseSelectedLayoutSegment.mockReturnValue('plugins')
+
+ render()
+
+ const container = screen.getByText('common.menus.plugins').closest('div')
+ expect(container).toHaveClass(
+ 'border-components-main-nav-nav-button-border',
+ )
+ expect(container).toHaveClass(
+ 'bg-components-main-nav-nav-button-bg-active',
+ )
+ })
+ })
+
+ describe('Task Status Indicators', () => {
+ it('renders Installing state (Inactive)', () => {
+ mockUsePluginTaskStatus.mockReturnValue({ isInstalling: true })
+
+ const { container } = render()
+
+ const downloadingIcon = container.querySelector('.install-icon')
+ expect(downloadingIcon).toBeInTheDocument()
+
+ const svgs = container.querySelectorAll('svg')
+ expect(svgs.length).toBe(1)
+ expect(svgs[0]).toHaveClass('install-icon')
+
+ expect(screen.queryByTestId('status-indicator')).not.toBeInTheDocument()
+ })
+
+ it('renders Installing With Error state (Inactive)', () => {
+ mockUsePluginTaskStatus.mockReturnValue({ isInstallingWithError: true })
+
+ const { container } = render()
+
+ const downloadingIcon = container.querySelector('.install-icon')
+ expect(downloadingIcon).toBeInTheDocument()
+
+ expect(screen.getByTestId('status-indicator')).toBeInTheDocument()
+ })
+
+ it('renders Failed state (Inactive)', () => {
+ mockUsePluginTaskStatus.mockReturnValue({ isFailed: true })
+
+ const { container } = render()
+
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ expect(svg).not.toHaveClass('install-icon')
+
+ expect(screen.getByTestId('status-indicator')).toBeInTheDocument()
+ })
+
+ it('renders Default icon when Active even if installing', () => {
+ mockUseSelectedLayoutSegment.mockReturnValue('plugins')
+ mockUsePluginTaskStatus.mockReturnValue({ isInstalling: true })
+
+ const { container } = render()
+
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ expect(svg).not.toHaveClass('install-icon')
+
+ expect(container.querySelector('.install-icon')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/header/tools-nav/index.spec.tsx b/web/app/components/header/tools-nav/index.spec.tsx
new file mode 100644
index 0000000000..dadb55eac5
--- /dev/null
+++ b/web/app/components/header/tools-nav/index.spec.tsx
@@ -0,0 +1,71 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ToolsNav from './index'
+
+const mockUseSelectedLayoutSegment = vi.fn()
+vi.mock('next/navigation', () => ({
+ useSelectedLayoutSegment: () => mockUseSelectedLayoutSegment(),
+}))
+
+vi.mock('@remixicon/react', () => ({
+ RiHammerFill: (props: React.ComponentProps<'svg'>) => (
+
+ ),
+ RiHammerLine: (props: React.ComponentProps<'svg'>) => (
+
+ ),
+}))
+
+describe('ToolsNav', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render standard inactive state correctly', () => {
+ mockUseSelectedLayoutSegment.mockReturnValue(null)
+
+ render()
+
+ const link = screen.getByRole('link')
+ expect(link).toHaveAttribute('href', '/tools')
+ expect(screen.getByText('common.menus.tools')).toBeInTheDocument()
+
+ expect(screen.getByTestId('icon-hammer-line')).toBeInTheDocument()
+ expect(screen.queryByTestId('icon-hammer-fill')).not.toBeInTheDocument()
+
+ expect(link).toHaveClass('text-components-main-nav-nav-button-text')
+ expect(link).toHaveClass(
+ 'hover:bg-components-main-nav-nav-button-bg-hover',
+ )
+ })
+
+ it('should render active state correctly', () => {
+ mockUseSelectedLayoutSegment.mockReturnValue('tools')
+
+ render()
+
+ const link = screen.getByRole('link')
+
+ expect(link).toHaveClass('bg-components-main-nav-nav-button-bg-active')
+ expect(link).toHaveClass(
+ 'text-components-main-nav-nav-button-text-active',
+ )
+ expect(link).toHaveClass('font-semibold')
+ expect(link).toHaveClass('shadow-md')
+
+ expect(screen.getByTestId('icon-hammer-fill')).toBeInTheDocument()
+ expect(screen.queryByTestId('icon-hammer-line')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should merge additional classNames', () => {
+ mockUseSelectedLayoutSegment.mockReturnValue(null)
+ render()
+
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('custom-test-class')
+ })
+ })
+})