test(web): increase test coverage for components inside header folder (#32392)

This commit is contained in:
akashseth-ifp
2026-02-24 10:14:10 +05:30
committed by GitHub
parent 0070891114
commit 2162cd1a69
16 changed files with 2404 additions and 0 deletions

View File

@@ -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 }) => <button type="button" aria-label="close notice" onClick={onClick} />,
}))
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<string, unknown>
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(<MaintenanceNotice />)
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(<MaintenanceNotice />)
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(<MaintenanceNotice />)
expect(container.firstChild).toBeNull()
})
})
describe('User Interactions', () => {
it('should close the notice when X is clicked', () => {
render(<MaintenanceNotice />)
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(<MaintenanceNotice />)
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(<MaintenanceNotice />)
const desc = screen.getByText('Notice Description')
fireEvent.click(desc)
expect(windowOpenSpy).not.toHaveBeenCalled()
})
it('should not jump when href is empty', () => {
setNoticeHref('')
render(<MaintenanceNotice />)
const desc = screen.getByText('Notice Description')
fireEvent.click(desc)
expect(windowOpenSpy).not.toHaveBeenCalled()
})
})
})

View File

@@ -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(<AppBack curApp={mockApp} />)
expect(screen.getByText('common.menus.apps')).toBeInTheDocument()
})
it('should keep apps label visible while hovering', () => {
render(<AppBack curApp={mockApp} />)
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(<AppBack curApp={app1} />)
expect(screen.getByText('common.menus.apps')).toBeInTheDocument()
rerender(<AppBack curApp={app2} />)
expect(screen.getByText('common.menus.apps')).toBeInTheDocument()
})
})

View File

@@ -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
? (
<button
type="button"
data-testid="create-app-template-dialog"
onClick={() => {
onClose()
onSuccess()
}}
>
Create Template
</button>
)
: null,
}))
vi.mock('@/app/components/app/create-app-modal', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) =>
show
? (
<button
type="button"
data-testid="create-app-modal"
onClick={() => {
onClose()
onSuccess()
}}
>
Create App
</button>
)
: null,
}))
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) =>
show
? (
<button
type="button"
data-testid="create-from-dsl-modal"
onClick={() => {
onClose()
onSuccess()
}}
>
Create from DSL
</button>
)
: null,
}))
vi.mock('../nav', () => ({
default: ({
onCreate,
onLoadMore,
navigationItems,
}: {
onCreate: (state: string) => void
onLoadMore?: () => void
navigationItems?: Array<{ id: string, name: string, link: string }>
}) => (
<div data-testid="nav">
<ul data-testid="nav-items">
{(navigationItems ?? []).map(item => (
<li key={item.id}>{`${item.name} -> ${item.link}`}</li>
))}
</ul>
<button type="button" onClick={() => onCreate('blank')} data-testid="create-blank">
Create Blank
</button>
<button type="button" onClick={() => onCreate('template')} data-testid="create-template">
Create Template
</button>
<button type="button" onClick={() => onCreate('dsl')} data-testid="create-dsl">
Create DSL
</button>
<button type="button" onClick={onLoadMore} data-testid="load-more">
Load More
</button>
</div>
),
}))
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<typeof useParams>)
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType<typeof useAppContext>)
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<typeof useInfiniteAppList>)
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(<AppNav />)
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(<AppNav />)
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(<AppNav />)
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(<AppNav />)
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(<AppNav />)
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(<AppNav />)
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(<AppNav />)
await user.click(screen.getByTestId('load-more'))
expect(fetchNextPage).not.toHaveBeenCalled()
})
})

View File

@@ -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
? (
<div data-testid="create-app-dialog">
<button onClick={onClose}>Close</button>
</div>
)
: 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<typeof useRouter>)
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceEditor: true,
} as unknown as ReturnType<typeof useAppContext>)
})
describe('Rendering', () => {
it('should render current app name', () => {
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
expect(screen.getByText('App 1')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should open menu and show app items', async () => {
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
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(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
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<typeof useAppContext>)
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
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(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
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<typeof useAppContext>)
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
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(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
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(<AppSelector appItems={[]} curApp={mockCurApp} />)
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()
})
})
})

View File

@@ -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: () => <div data-testid="active-icon" />,
RiBook2Line: () => <div data-testid="inactive-icon" />,
RiArrowDownSLine: () => <div data-testid="arrow-down-icon" />,
RiArrowRightSLine: () => <div data-testid="arrow-right-icon" />,
RiAddLine: () => <div data-testid="add-icon" />,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading" />,
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: () => <div data-testid="app-icon" />,
}))
vi.mock('@/app/components/app/type-selector', () => ({
AppTypeIcon: () => <div data-testid="app-type-icon" />,
}))
vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
ArrowNarrowLeft: () => <div data-testid="arrow-left-icon" />,
}))
vi.mock('@/app/components/base/icons/src/vender/line/files', () => ({
FileArrow01: () => <div data-testid="file-arrow-icon" />,
FilePlus01: () => <div data-testid="file-plus-1-icon" />,
FilePlus02: () => <div data-testid="file-plus-2-icon" />,
}))
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<typeof useRouter>)
vi.mocked(useParams).mockReturnValue({ datasetId: 'dataset-1' })
vi.mocked(useSelectedLayoutSegment).mockReturnValue('datasets')
vi.mocked(useDatasetDetail).mockReturnValue({
data: mockDataset,
} as unknown as ReturnType<typeof useDatasetDetail>)
vi.mocked(useDatasetList).mockReturnValue({
data: mockDatasetList,
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceEditor: true,
} as unknown as ReturnType<typeof useAppContext>)
})
describe('Rendering', () => {
it('should render the navigation component', () => {
render(<DatasetNav />)
expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
})
it('should render without current dataset correctly', () => {
vi.mocked(useDatasetDetail).mockReturnValue({
data: undefined,
} as unknown as ReturnType<typeof useDatasetDetail>)
render(<DatasetNav />)
expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
})
})
describe('Navigation Items logic', () => {
it('should generate correct links for different dataset types', () => {
render(<DatasetNav />)
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(<DatasetNav />)
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(<DatasetNav />)
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<typeof useDatasetDetail>)
render(<DatasetNav />)
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(<DatasetNav />)
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<typeof useDatasetList>)
render(<DatasetNav />)
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()
})
})
})

View File

@@ -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(<EnvNav />)
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(<EnvNav />)
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(<EnvNav />)
expect(
screen.getByText('common.environment.development'),
).toBeInTheDocument()
})
})

View File

@@ -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(<ExploreNav />)
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(<ExploreNav />)
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(<ExploreNav className="custom-test-class" />)
const link = screen.getByRole('link')
expect(link).toHaveClass('custom-test-class')
})
})

View File

@@ -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(
<HeaderWrapper>
<div data-testid="child">Test Child</div>
</HeaderWrapper>,
)
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(
<HeaderWrapper>
<div>Workflow Content</div>
</HeaderWrapper>,
)
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(
<HeaderWrapper>
<div>Pipeline Content</div>
</HeaderWrapper>,
)
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(
<HeaderWrapper>
<div>App Content</div>
</HeaderWrapper>,
)
expect(screen.getByText('App Content')).toBeInTheDocument()
})
it('should keep children mounted when unrelated events are emitted', () => {
vi.mocked(usePathname).mockReturnValue('/some/path/workflow')
render(
<HeaderWrapper>
<div>Workflow Content</div>
</HeaderWrapper>,
)
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(
<HeaderWrapper>
<div>Content Without Emitter</div>
</HeaderWrapper>,
)
expect(screen.getByText('Content Without Emitter')).toBeInTheDocument()
})
})

View File

@@ -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 () => <div data-testid={testId} />
}
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 }) => (
<button data-testid="plan-badge" onClick={onClick} data-plan={plan} />
),
}))
vi.mock('@/context/workspace-context', () => ({
WorkspaceProvider: ({ children }: { children?: React.ReactNode }) => children,
}))
vi.mock('next/link', () => ({
default: ({ children, href }: { children?: React.ReactNode, href?: string }) => <a href={href}>{children}</a>,
}))
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(<Header />)
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(<Header />)
expect(screen.getByTestId('license-nav')).toBeInTheDocument()
expect(screen.queryByTestId('plan-badge')).not.toBeInTheDocument()
mockEnableBilling = true
rerender(<Header />)
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(<Header />)
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(<Header />)
fireEvent.click(screen.getByTestId('plan-badge'))
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
mockPlanType = 'professional'
rerender(<Header />)
fireEvent.click(screen.getByTestId('plan-badge'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1)
})
it('should render mobile layout without env nav', () => {
mockMedia = 'mobile'
render(<Header />)
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(<Header />)
expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
expect(screen.queryByTestId('dify-logo')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react'
import Indicator from './index'
describe('Indicator', () => {
it('should render with default props', () => {
render(<Indicator />)
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(<Indicator color="orange" />)
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(<Indicator color="red" />)
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(<Indicator color="blue" />)
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(<Indicator color="yellow" />)
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(<Indicator color="gray" />)
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(<Indicator className="custom-class" />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('custom-class')
})
})

View File

@@ -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(<LicenseNav />)
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(<LicenseNav />)
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(<LicenseNav />)
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(<LicenseNav />)
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(<LicenseNav />)
expect(screen.getByText(/license\.expiring_plural/)).toBeInTheDocument()
expect(screen.getByText(/count":5/)).toBeInTheDocument()
})
})

View File

@@ -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<MenuContextValue | null>(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 (
<MenuContext.Provider value={value}>
{typeof children === 'function' ? children({ open }) : children}
</MenuContext.Provider>
)
}
const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => {
const context = React.useContext(MenuContext)
const handleClick = () => {
context?.setOpen(!context.open)
onClick?.()
}
return (
<button type="button" aria-expanded={context?.open ?? false} onClick={handleClick} {...props}>
{children}
</button>
)
}
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 (
<Component role={role ?? 'menu'} {...props}>
{children}
</Component>
)
}
const MenuItem = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => (
<Component role={role ?? 'menuitem'} {...props}>
{children}
</Component>
)
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: <span data-testid="default-icon">Icon</span>,
activeIcon: <span data-testid="active-icon">Active Icon</span>,
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<typeof useRouter>)
})
describe('Rendering', () => {
it('should render correctly when activated', () => {
render(<Nav {...defaultProps} />)
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(<Nav {...defaultProps} />)
expect(screen.getByTestId('default-icon')).toBeInTheDocument()
})
it('should handle array activeSegment', () => {
render(<Nav {...defaultProps} activeSegment={['explore', 'apps']} />)
expect(screen.getByTestId('active-icon')).toBeInTheDocument()
})
it('should not show hover background if not activated', () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('other')
const { container } = render(<Nav {...defaultProps} />)
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(<Nav {...defaultProps} />)
const link = screen.getByRole('link')
fireEvent.click(link.firstChild!)
expect(mockSetAppDetail).toHaveBeenCalled()
})
it('should not call setAppDetail when clicked with modifier keys', () => {
render(<Nav {...defaultProps} />)
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(<Nav {...defaultProps} curNav={curNav} />)
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(<Nav {...defaultProps} curNav={curNav} />)
expect(screen.getByText('/')).toBeInTheDocument()
expect(screen.getByText('Item 1')).toBeInTheDocument()
})
it('should open menu and show items when clicked', async () => {
render(<Nav {...defaultProps} curNav={curNav} />)
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(<Nav {...defaultProps} curNav={curNav} />)
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(<Nav {...defaultProps} curNav={curNav} />)
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(<Nav {...defaultProps} curNav={curNav} />)
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(<Nav {...defaultProps} curNav={curNav} isApp />)
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(<Nav {...defaultProps} curNav={curNav} />)
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(<Nav {...defaultProps} curNav={curNav} isLoadingMore />)
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(<Nav {...defaultProps} curNav={curNav} />)
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()
})
})
})

View File

@@ -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<MenuContextValue | null>(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 (
<MenuContext.Provider value={value}>
{typeof children === 'function' ? children({ open }) : children}
</MenuContext.Provider>
)
}
const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => {
const context = React.useContext(MenuContext)
const handleClick = () => {
context?.setOpen(!context.open)
onClick?.()
}
return (
<button type="button" aria-expanded={context?.open ?? false} onClick={handleClick} {...props}>
{children}
</button>
)
}
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 (
<Component role={role ?? 'menu'} {...props}>
{children}
</Component>
)
}
const MenuItem = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => (
<Component role={role ?? 'menuitem'} {...props}>
{children}
</Component>
)
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<typeof useRouter>)
})
describe('Rendering', () => {
it('should render current nav name', () => {
render(<NavSelector {...defaultProps} />)
expect(screen.getByText('Item 1')).toBeInTheDocument()
})
it('should show loading indicator when isLoadingMore is true', async () => {
render(<NavSelector {...defaultProps} isLoadingMore />)
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(<NavSelector {...defaultProps} />)
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(<NavSelector {...defaultProps} />)
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(<NavSelector {...defaultProps} />)
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(<NavSelector {...defaultProps} />)
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(<NavSelector {...defaultProps} isApp />)
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(<NavSelector {...defaultProps} />)
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(<NavSelector {...defaultProps} />)
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(<NavSelector {...propsWithoutOnLoadMore} />)
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
})
})
})

View File

@@ -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(<PlanBadge plan={Plan.sandbox} />)
expect(container.firstChild).toBeNull()
})
it('should render upgrade badge when plan is sandbox and sandboxAsUpgrade is true', () => {
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({ isFetchedPlan: true }),
)
render(<PlanBadge plan={Plan.sandbox} sandboxAsUpgrade={true} />)
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(<PlanBadge plan={Plan.sandbox} sandboxAsUpgrade={false} />)
expect(screen.getByText(Plan.sandbox)).toBeInTheDocument()
})
it('should render professional badge when plan is professional', () => {
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({ isFetchedPlan: true }),
)
render(<PlanBadge plan={Plan.professional} />)
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(<PlanBadge plan={Plan.professional} />)
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(<PlanBadge plan={Plan.team} />)
expect(screen.getByText(Plan.team)).toBeInTheDocument()
})
it('should return null when plan is enterprise', () => {
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({ isFetchedPlan: true }),
)
const { container } = render(<PlanBadge plan={Plan.enterprise} />)
expect(container.firstChild).toBeNull()
})
it('should trigger onClick when clicked', () => {
const handleClick = vi.fn()
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({ isFetchedPlan: true }),
)
render(<PlanBadge plan={Plan.team} onClick={handleClick} />)
fireEvent.click(screen.getByText(Plan.team))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should handle allowHover prop', () => {
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({ isFetchedPlan: true }),
)
const { container } = render(
<PlanBadge plan={Plan.team} allowHover={true} />,
)
expect(container.firstChild).not.toBeNull()
})
})

View File

@@ -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(<PluginsNav />)
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(<PluginsNav />)
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(<PluginsNav />)
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(<PluginsNav />)
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(<PluginsNav />)
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(<PluginsNav />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
expect(svg).not.toHaveClass('install-icon')
expect(container.querySelector('.install-icon')).not.toBeInTheDocument()
})
})
})

View File

@@ -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'>) => (
<svg data-testid="icon-hammer-fill" {...props} />
),
RiHammerLine: (props: React.ComponentProps<'svg'>) => (
<svg data-testid="icon-hammer-line" {...props} />
),
}))
describe('ToolsNav', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render standard inactive state correctly', () => {
mockUseSelectedLayoutSegment.mockReturnValue(null)
render(<ToolsNav />)
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(<ToolsNav />)
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(<ToolsNav className="custom-test-class" />)
const link = screen.getByRole('link')
expect(link).toHaveClass('custom-test-class')
})
})
})