mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 18:05:11 +00:00
test(web): increase test coverage for components inside header folder (#32392)
This commit is contained in:
120
web/app/components/header/ maintenance-notice.spec.tsx
Normal file
120
web/app/components/header/ maintenance-notice.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
36
web/app/components/header/app-back/index.spec.tsx
Normal file
36
web/app/components/header/app-back/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
267
web/app/components/header/app-nav/index.spec.tsx
Normal file
267
web/app/components/header/app-nav/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
171
web/app/components/header/app-selector/index.spec.tsx
Normal file
171
web/app/components/header/app-selector/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
268
web/app/components/header/dataset-nav/index.spec.tsx
Normal file
268
web/app/components/header/dataset-nav/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
52
web/app/components/header/env-nav/index.spec.tsx
Normal file
52
web/app/components/header/env-nav/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
45
web/app/components/header/explore-nav/index.spec.tsx
Normal file
45
web/app/components/header/explore-nav/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
112
web/app/components/header/header-wrapper.spec.tsx
Normal file
112
web/app/components/header/header-wrapper.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
191
web/app/components/header/index.spec.tsx
Normal file
191
web/app/components/header/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
79
web/app/components/header/indicator/index.spec.tsx
Normal file
79
web/app/components/header/indicator/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
92
web/app/components/header/license-env/index.spec.tsx
Normal file
92
web/app/components/header/license-env/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
376
web/app/components/header/nav/index.spec.tsx
Normal file
376
web/app/components/header/nav/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
308
web/app/components/header/nav/nav-selector/index.spec.tsx
Normal file
308
web/app/components/header/nav/nav-selector/index.spec.tsx
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
104
web/app/components/header/plan-badge/index.spec.tsx
Normal file
104
web/app/components/header/plan-badge/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
112
web/app/components/header/plugins-nav/index.spec.tsx
Normal file
112
web/app/components/header/plugins-nav/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
71
web/app/components/header/tools-nav/index.spec.tsx
Normal file
71
web/app/components/header/tools-nav/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user