mirror of
https://github.com/langgenius/dify.git
synced 2026-07-23 20:18:40 +08:00
267 lines
8.1 KiB
TypeScript
267 lines
8.1 KiB
TypeScript
import type { ReactNode } from 'react'
|
|
import type { Mock } from 'vitest'
|
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
|
import { fireEvent, screen } from '@testing-library/react'
|
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
|
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
|
import { usePathname } from '@/next/navigation'
|
|
import { render } from '@/test/console/render'
|
|
import MainNavLayout from '../layout'
|
|
|
|
const mockConsoleState = vi.hoisted(() => ({
|
|
current: {
|
|
isCurrentWorkspaceDatasetOperator: false,
|
|
isCurrentWorkspaceEditor: true,
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/app/components/header', () => ({
|
|
default: () => <div data-testid="desktop-header">Header</div>,
|
|
}))
|
|
|
|
vi.mock('@/app/components/header/header-wrapper', () => ({
|
|
default: ({ children }: { children: ReactNode }) => (
|
|
<div data-testid="header-wrapper">{children}</div>
|
|
),
|
|
}))
|
|
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
|
return {
|
|
...actual,
|
|
useSuspenseQuery: vi.fn(),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/context/workspace-state', async () => {
|
|
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
|
return createWorkspaceStateModuleMock(() => mockConsoleState.current)
|
|
})
|
|
|
|
vi.mock('@/features/agent-v2/feature-flag', () => ({
|
|
isAgentV2Enabled: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/next/navigation', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/next/navigation')>()
|
|
return {
|
|
...actual,
|
|
usePathname: vi.fn(),
|
|
}
|
|
})
|
|
|
|
vi.mock('../index', () => ({
|
|
MainNav: ({ className }: { className?: string }) => (
|
|
<aside className={className} data-testid="main-nav">
|
|
MainNav
|
|
</aside>
|
|
),
|
|
}))
|
|
|
|
describe('MainNavLayout', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
localStorage.clear()
|
|
useAppStore.getState().setAppDetail()
|
|
;(usePathname as Mock).mockReturnValue('/apps')
|
|
mockConsoleState.current = {
|
|
isCurrentWorkspaceDatasetOperator: false,
|
|
isCurrentWorkspaceEditor: true,
|
|
}
|
|
;(useSuspenseQuery as Mock).mockReturnValue({
|
|
data: {
|
|
enable_app_deploy: true,
|
|
},
|
|
})
|
|
;(isAgentV2Enabled as Mock).mockReturnValue(true)
|
|
})
|
|
|
|
it('renders desktop main nav instead of the desktop header', () => {
|
|
render(
|
|
<MainNavLayout>
|
|
<div>content</div>
|
|
</MainNavLayout>,
|
|
)
|
|
|
|
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
|
expect(screen.queryByTestId('desktop-header')).not.toBeInTheDocument()
|
|
expect(screen.getByText('content')).toBeInTheDocument()
|
|
})
|
|
|
|
it('uses the main nav without the desktop header wrapper', () => {
|
|
render(
|
|
<MainNavLayout>
|
|
<div>content</div>
|
|
</MainNavLayout>,
|
|
)
|
|
|
|
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
|
expect(screen.queryByTestId('header-wrapper')).not.toBeInTheDocument()
|
|
expect(screen.queryByTestId('desktop-header')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('renders one main landmark as the skip navigation target', () => {
|
|
render(
|
|
<MainNavLayout>
|
|
<div>content</div>
|
|
</MainNavLayout>,
|
|
)
|
|
|
|
const main = screen.getByRole('main')
|
|
|
|
expect(screen.getAllByRole('main')).toHaveLength(1)
|
|
expect(main).toHaveAttribute('id', 'main-content')
|
|
expect(main).toHaveAttribute('tabIndex', '-1')
|
|
expect(main).toHaveClass(
|
|
'outline-hidden',
|
|
'focus:outline-hidden',
|
|
'focus-visible:outline-hidden',
|
|
)
|
|
expect(main).toHaveTextContent('content')
|
|
})
|
|
|
|
it('renders skip navigation before the repeated main navigation', () => {
|
|
const { container } = render(
|
|
<MainNavLayout>
|
|
<div>content</div>
|
|
</MainNavLayout>,
|
|
)
|
|
|
|
const skipLink = screen.getByRole('link', { name: /(?:^|\.)navigation\.skipToMain(?=$|:)/ })
|
|
|
|
expect(skipLink).toHaveAttribute('href', '#main-content')
|
|
expect(skipLink).toHaveClass(
|
|
'outline-hidden',
|
|
'focus-visible:ring-2',
|
|
'focus-visible:ring-state-accent-solid',
|
|
)
|
|
expect(
|
|
container.querySelector(
|
|
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
),
|
|
).toBe(skipLink)
|
|
})
|
|
|
|
it('moves focus to the main content when skip navigation is activated', () => {
|
|
render(
|
|
<MainNavLayout>
|
|
<div>content</div>
|
|
</MainNavLayout>,
|
|
)
|
|
|
|
const skipLink = screen.getByRole('link', { name: /(?:^|\.)navigation\.skipToMain(?=$|:)/ })
|
|
const main = screen.getByRole('main')
|
|
|
|
fireEvent.click(skipLink)
|
|
|
|
expect(main).toHaveFocus()
|
|
})
|
|
|
|
it.each(['/datasets/dataset-1/documents', '/datasets/dataset-1/documents/document-1/settings'])(
|
|
'renders the detail sidebar slot outside the single skip navigation target on route %s',
|
|
(pathname) => {
|
|
;(usePathname as Mock).mockReturnValue(pathname)
|
|
|
|
render(
|
|
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
|
<div>dataset detail</div>
|
|
</MainNavLayout>,
|
|
)
|
|
|
|
const main = screen.getByRole('main')
|
|
const detailSidebar = screen.getByRole('complementary', { name: 'Detail sidebar' })
|
|
|
|
expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument()
|
|
expect(screen.getAllByRole('main')).toHaveLength(1)
|
|
expect(main).toHaveAttribute('id', 'main-content')
|
|
expect(main).toHaveTextContent('dataset detail')
|
|
expect(main).not.toContainElement(detailSidebar)
|
|
},
|
|
)
|
|
|
|
it.each(['/datasets/create', '/datasets/dataset-1/documents/create', '/deployments/create'])(
|
|
'keeps the global main nav on collection and creation route %s',
|
|
(pathname) => {
|
|
;(usePathname as Mock).mockReturnValue(pathname)
|
|
|
|
render(
|
|
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
|
<div>content</div>
|
|
</MainNavLayout>,
|
|
)
|
|
|
|
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
|
expect(
|
|
screen.queryByRole('complementary', { name: 'Detail sidebar' }),
|
|
).not.toBeInTheDocument()
|
|
},
|
|
)
|
|
|
|
it.each([
|
|
{
|
|
label: 'agent detail route for dataset operators',
|
|
pathname: '/agents/agent-1/configure',
|
|
consoleState: {
|
|
isCurrentWorkspaceDatasetOperator: true,
|
|
isCurrentWorkspaceEditor: true,
|
|
},
|
|
systemFeatures: {
|
|
enable_app_deploy: true,
|
|
},
|
|
},
|
|
{
|
|
label: 'deployment detail route for non-editor workspaces',
|
|
pathname: '/deployments/app-instance-1/overview',
|
|
consoleState: {
|
|
isCurrentWorkspaceDatasetOperator: false,
|
|
isCurrentWorkspaceEditor: false,
|
|
},
|
|
systemFeatures: {
|
|
enable_app_deploy: true,
|
|
},
|
|
},
|
|
{
|
|
label: 'deployment detail route when deployment is disabled',
|
|
pathname: '/deployments/app-instance-1/overview',
|
|
consoleState: {
|
|
isCurrentWorkspaceDatasetOperator: false,
|
|
isCurrentWorkspaceEditor: true,
|
|
},
|
|
systemFeatures: {
|
|
enable_app_deploy: false,
|
|
},
|
|
},
|
|
])('keeps the global main nav on $label', ({ pathname, consoleState, systemFeatures }) => {
|
|
;(usePathname as Mock).mockReturnValue(pathname)
|
|
mockConsoleState.current = consoleState
|
|
;(useSuspenseQuery as Mock).mockReturnValue({
|
|
data: systemFeatures,
|
|
})
|
|
|
|
render(
|
|
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
|
<div>detail route content</div>
|
|
</MainNavLayout>,
|
|
)
|
|
|
|
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
|
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
|
expect(screen.getAllByRole('main')).toHaveLength(1)
|
|
expect(screen.getByRole('main')).toHaveTextContent('detail route content')
|
|
})
|
|
|
|
it('clears app detail state after leaving app routes', () => {
|
|
useAppStore
|
|
.getState()
|
|
.setAppDetail({ id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'])
|
|
;(usePathname as Mock).mockReturnValue('/datasets')
|
|
|
|
render(
|
|
<MainNavLayout>
|
|
<div>content</div>
|
|
</MainNavLayout>,
|
|
)
|
|
|
|
expect(useAppStore.getState().appDetail).toBeUndefined()
|
|
})
|
|
})
|