diff --git a/web/app/components/header/ maintenance-notice.spec.tsx b/web/app/components/header/ maintenance-notice.spec.tsx new file mode 100644 index 0000000000..157b03eb17 --- /dev/null +++ b/web/app/components/header/ maintenance-notice.spec.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { NOTICE_I18N } from '@/i18n-config/language' +import MaintenanceNotice from './maintenance-notice' + +vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({ + X: ({ onClick }: { onClick?: () => void }) => + ) + : null, +})) + +vi.mock('@/app/components/app/create-app-modal', () => ({ + default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) => + show + ? ( + + ) + : null, +})) + +vi.mock('@/app/components/app/create-from-dsl-modal', () => ({ + default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) => + show + ? ( + + ) + : null, +})) + +vi.mock('../nav', () => ({ + default: ({ + onCreate, + onLoadMore, + navigationItems, + }: { + onCreate: (state: string) => void + onLoadMore?: () => void + navigationItems?: Array<{ id: string, name: string, link: string }> + }) => ( +
+ + + + + +
+ ), +})) + +const mockAppData = [ + { + id: 'app-1', + name: 'App 1', + mode: AppModeEnum.AGENT_CHAT, + icon_type: 'emoji', + icon: '🤖', + icon_background: null, + icon_url: null, + }, +] + +const mockUseParams = vi.mocked(useParams) +const mockUseAppContext = vi.mocked(useAppContext) +const mockUseAppStore = vi.mocked(useAppStore) +const mockUseInfiniteAppList = vi.mocked(useInfiniteAppList) +let mockAppDetail: { id: string, name: string } | null = null + +const setupDefaultMocks = (options?: { + hasNextPage?: boolean + refetch?: () => void + fetchNextPage?: () => void + isEditor?: boolean + appData?: typeof mockAppData +}) => { + const refetch = options?.refetch ?? vi.fn() + const fetchNextPage = options?.fetchNextPage ?? vi.fn() + + mockUseParams.mockReturnValue({ appId: 'app-1' } as ReturnType) + mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType) + mockUseAppStore.mockImplementation((selector: unknown) => (selector as (state: { appDetail: { id: string, name: string } | null }) => unknown)({ appDetail: mockAppDetail })) + mockUseInfiniteAppList.mockReturnValue({ + data: { pages: [{ data: options?.appData ?? mockAppData }] }, + fetchNextPage, + hasNextPage: options?.hasNextPage ?? false, + isFetchingNextPage: false, + refetch, + } as ReturnType) + + return { refetch, fetchNextPage } +} + +describe('AppNav', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetail = null + setupDefaultMocks() + }) + + it('should build editor links and update app name when app detail changes', async () => { + setupDefaultMocks({ + isEditor: true, + appData: [ + { + id: 'app-1', + name: 'App 1', + mode: AppModeEnum.AGENT_CHAT, + icon_type: 'emoji', + icon: '🤖', + icon_background: null, + icon_url: null, + }, + { + id: 'app-2', + name: 'App 2', + mode: AppModeEnum.WORKFLOW, + icon_type: 'emoji', + icon: '⚙️', + icon_background: null, + icon_url: null, + }, + ], + }) + + const { rerender } = render() + + expect(screen.getByText('App 1 -> /app/app-1/configuration')).toBeInTheDocument() + expect(screen.getByText('App 2 -> /app/app-2/workflow')).toBeInTheDocument() + + mockAppDetail = { id: 'app-1', name: 'Updated App Name' } + rerender() + + await waitFor(() => { + expect(screen.getByText('Updated App Name -> /app/app-1/configuration')).toBeInTheDocument() + }) + }) + + it('should open and close create app modal, then refetch', async () => { + const user = userEvent.setup() + const { refetch } = setupDefaultMocks() + render() + + await user.click(screen.getByTestId('create-blank')) + expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() + + await user.click(screen.getByTestId('create-app-modal')) + await waitFor(() => { + expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() + expect(refetch).toHaveBeenCalledTimes(1) + }) + }) + + it('should open and close template modal, then refetch', async () => { + const user = userEvent.setup() + const { refetch } = setupDefaultMocks() + render() + + await user.click(screen.getByTestId('create-template')) + expect(screen.getByTestId('create-app-template-dialog')).toBeInTheDocument() + + await user.click(screen.getByTestId('create-app-template-dialog')) + await waitFor(() => { + expect(screen.queryByTestId('create-app-template-dialog')).not.toBeInTheDocument() + expect(refetch).toHaveBeenCalledTimes(1) + }) + }) + + it('should open and close DSL modal, then refetch', async () => { + const user = userEvent.setup() + const { refetch } = setupDefaultMocks() + render() + + await user.click(screen.getByTestId('create-dsl')) + expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() + + await user.click(screen.getByTestId('create-from-dsl-modal')) + await waitFor(() => { + expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument() + expect(refetch).toHaveBeenCalledTimes(1) + }) + }) + + it('should load more when user clicks load more and more data is available', async () => { + const user = userEvent.setup() + const { fetchNextPage } = setupDefaultMocks({ hasNextPage: true }) + render() + + await user.click(screen.getByTestId('load-more')) + expect(fetchNextPage).toHaveBeenCalledTimes(1) + }) + + it('should not load more when user clicks load more and no data is available', async () => { + const user = userEvent.setup() + const { fetchNextPage } = setupDefaultMocks({ hasNextPage: false }) + render() + + await user.click(screen.getByTestId('load-more')) + expect(fetchNextPage).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/header/app-selector/index.spec.tsx b/web/app/components/header/app-selector/index.spec.tsx new file mode 100644 index 0000000000..f301de4580 --- /dev/null +++ b/web/app/components/header/app-selector/index.spec.tsx @@ -0,0 +1,171 @@ +import type { AppDetailResponse } from '@/models/app' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { useRouter } from 'next/navigation' +import { vi } from 'vitest' +import { useAppContext } from '@/context/app-context' +import AppSelector from './index' + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(), +})) + +// Mock app context +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +// Mock CreateAppDialog to avoid complex dependencies +vi.mock('@/app/components/app/create-app-dialog', () => ({ + default: ({ show, onClose }: { show: boolean, onClose: () => void }) => show + ? ( +
+ +
+ ) + : null, +})) + +describe('AppSelector Component', () => { + const mockPush = vi.fn() + const mockAppItems = [ + { id: '1', name: 'App 1' }, + { id: '2', name: 'App 2' }, + ] as unknown as AppDetailResponse[] + const mockCurApp = mockAppItems[0] + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useRouter).mockReturnValue({ + push: mockPush, + } as unknown as ReturnType) + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceEditor: true, + } as unknown as ReturnType) + }) + + describe('Rendering', () => { + it('should render current app name', () => { + render() + expect(screen.getByText('App 1')).toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('should open menu and show app items', async () => { + render() + + const button = screen.getByRole('button', { name: /App 1/i }) + await act(async () => { + fireEvent.click(button) + }) + + expect(screen.getByText('App 2')).toBeInTheDocument() + }) + + it('should navigate to configuration when an app is clicked and user is editor', async () => { + render() + + const button = screen.getByRole('button', { name: /App 1/i }) + await act(async () => { + fireEvent.click(button) + }) + + const app2Item = screen.getByText('App 2') + await act(async () => { + fireEvent.click(app2Item) + }) + + expect(mockPush).toHaveBeenCalledWith('/app/2/configuration') + }) + + it('should navigate to overview when an app is clicked and user is not editor', async () => { + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceEditor: false, + } as unknown as ReturnType) + + render() + + const button = screen.getByRole('button', { name: /App 1/i }) + await act(async () => { + fireEvent.click(button) + }) + + const app2Item = screen.getByText('App 2') + await act(async () => { + fireEvent.click(app2Item) + }) + + expect(mockPush).toHaveBeenCalledWith('/app/2/overview') + }) + }) + + describe('New App Dialog', () => { + it('should show "New App" button for editor and open dialog', async () => { + render() + + const button = screen.getByRole('button', { name: /App 1/i }) + await act(async () => { + fireEvent.click(button) + }) + + const newAppBtn = screen.getByText('common.menus.newApp') + await act(async () => { + fireEvent.click(newAppBtn) + }) + + expect(screen.getByTestId('create-app-dialog')).toBeInTheDocument() + }) + + it('should not show "New App" button for non-editor', async () => { + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceEditor: false, + } as unknown as ReturnType) + + render() + + const button = screen.getByRole('button', { name: /App 1/i }) + await act(async () => { + fireEvent.click(button) + }) + + expect(screen.queryByText('common.menus.newApp')).not.toBeInTheDocument() + }) + + it('should close dialog when onClose is called', async () => { + render() + + const button = screen.getByRole('button', { name: /App 1/i }) + await act(async () => { + fireEvent.click(button) + }) + + const newAppBtn = screen.getByText('common.menus.newApp') + await act(async () => { + fireEvent.click(newAppBtn) + }) + + const closeBtn = screen.getByText('Close') + await act(async () => { + fireEvent.click(closeBtn) + }) + + expect(screen.queryByTestId('create-app-dialog')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render nothing in menu if appItems is empty', async () => { + render() + + const button = screen.getByRole('button', { name: /App 1/i }) + await act(async () => { + fireEvent.click(button) + }) + + expect(screen.queryByText('App 2')).not.toBeInTheDocument() + // "New App" should still be there if editor + expect(screen.getByText('common.menus.newApp')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/dataset-nav/index.spec.tsx b/web/app/components/header/dataset-nav/index.spec.tsx new file mode 100644 index 0000000000..8c1b5952a7 --- /dev/null +++ b/web/app/components/header/dataset-nav/index.spec.tsx @@ -0,0 +1,268 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react' +import { + useParams, + useRouter, + useSelectedLayoutSegment, +} from 'next/navigation' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppContext } from '@/context/app-context' +import { + useDatasetDetail, + useDatasetList, +} from '@/service/knowledge/use-dataset' +import DatasetNav from './index' + +vi.mock('next/navigation', () => ({ + useParams: vi.fn(), + useRouter: vi.fn(), + useSelectedLayoutSegment: vi.fn(), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetDetail: vi.fn(), + useDatasetList: vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +vi.mock('@remixicon/react', () => ({ + RiBook2Fill: () =>
, + RiBook2Line: () =>
, + RiArrowDownSLine: () =>
, + RiArrowRightSLine: () =>
, + RiAddLine: () =>
, +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/app/type-selector', () => ({ + AppTypeIcon: () =>
, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ + ArrowNarrowLeft: () =>
, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/files', () => ({ + FileArrow01: () =>
, + FilePlus01: () =>
, + FilePlus02: () =>
, +})) + +describe('DatasetNav', () => { + const mockPush = vi.fn() + const mockFetchNextPage = vi.fn() + + const mockDataset = { + id: 'dataset-1', + name: 'Test Dataset', + runtime_mode: 'general', + icon_info: { + icon: 'book', + icon_type: 'image', + icon_background: '#fff', + icon_url: '/url', + }, + provider: 'vendor', + } + + const mockDatasetList = { + pages: [ + { + data: [ + mockDataset, + { + id: 'dataset-2', + name: 'Pipeline Dataset', + runtime_mode: 'rag_pipeline', + is_published: false, + icon_info: { icon: 'pipeline' }, + provider: 'vendor', + }, + { + id: 'dataset-3', + name: 'External Dataset', + runtime_mode: 'general', + icon_info: { icon: 'external' }, + provider: 'external', + }, + { + id: 'dataset-4', + name: 'Published Pipeline', + runtime_mode: 'rag_pipeline', + is_published: true, + icon_info: { icon: 'pipeline' }, + provider: 'vendor', + }, + ], + }, + ], + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useRouter).mockReturnValue({ + push: mockPush, + } as unknown as ReturnType) + vi.mocked(useParams).mockReturnValue({ datasetId: 'dataset-1' }) + vi.mocked(useSelectedLayoutSegment).mockReturnValue('datasets') + vi.mocked(useDatasetDetail).mockReturnValue({ + data: mockDataset, + } as unknown as ReturnType) + vi.mocked(useDatasetList).mockReturnValue({ + data: mockDatasetList, + fetchNextPage: mockFetchNextPage, + hasNextPage: true, + isFetchingNextPage: false, + } as unknown as ReturnType) + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceEditor: true, + } as unknown as ReturnType) + }) + + describe('Rendering', () => { + it('should render the navigation component', () => { + render() + expect(screen.getByText('common.menus.datasets')).toBeInTheDocument() + }) + + it('should render without current dataset correctly', () => { + vi.mocked(useDatasetDetail).mockReturnValue({ + data: undefined, + } as unknown as ReturnType) + render() + expect(screen.getByText('common.menus.datasets')).toBeInTheDocument() + }) + }) + + describe('Navigation Items logic', () => { + it('should generate correct links for different dataset types', () => { + render() + + const selector = screen.getByRole('button', { name: /Test Dataset/i }) + fireEvent.click(selector) + + const menu = screen.getByRole('menu') + expect(within(menu).getByText('Test Dataset')).toBeInTheDocument() + expect(within(menu).getByText('Pipeline Dataset')).toBeInTheDocument() + expect(within(menu).getByText('External Dataset')).toBeInTheDocument() + }) + + it('should navigate to correct link when an item is clicked', () => { + render() + const selector = screen.getByRole('button', { name: /Test Dataset/i }) + fireEvent.click(selector) + + const menu = screen.getByRole('menu') + const pipelineItem = within(menu).getByText('Pipeline Dataset') + fireEvent.click(pipelineItem) + + // dataset-2 is rag_pipeline and not published -> /datasets/dataset-2/pipeline + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-2/pipeline') + + fireEvent.click(selector) + const menu2 = screen.getByRole('menu') + const externalItem = within(menu2).getByText('External Dataset') + fireEvent.click(externalItem) + // dataset-3 is provider external -> /datasets/dataset-3/hitTesting + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-3/hitTesting') + + fireEvent.click(selector) + const menu3 = screen.getByRole('menu') + const publishedItem = within(menu3).getByText('Published Pipeline') + fireEvent.click(publishedItem) + // dataset-4 is rag_pipeline and published -> /datasets/dataset-4/documents + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-4/documents') + }) + }) + + describe('User Interactions', () => { + it('should call router.push with correct path when creating a general dataset', () => { + render() + + const selector = screen.getByRole('button', { name: /Test Dataset/i }) + fireEvent.click(selector) + + const menu = screen.getByRole('menu') + const createBtn = within(menu).getByText('common.menus.newDataset') + fireEvent.click(createBtn) + + expect(mockPush).toHaveBeenCalledWith('/datasets/create') + }) + + it('should call router.push with correct path when creating a pipeline dataset', () => { + vi.mocked(useDatasetDetail).mockReturnValue({ + data: { ...mockDataset, runtime_mode: 'rag_pipeline' }, + } as unknown as ReturnType) + + render() + const selector = screen.getByRole('button', { name: /Test Dataset/i }) + fireEvent.click(selector) + + const menu = screen.getByRole('menu') + const createBtn = within(menu).getByText('common.menus.newDataset') + fireEvent.click(createBtn) + + expect(mockPush).toHaveBeenCalledWith('/datasets/create-from-pipeline') + }) + + it('should trigger fetchNextPage when loading more', () => { + vi.useFakeTimers() + render() + const selector = screen.getByRole('button', { name: /Test Dataset/i }) + fireEvent.click(selector) + + const menu = screen.getByRole('menu') + const scrollContainer = menu.querySelector('.overflow-auto') + if (scrollContainer) { + Object.defineProperty(scrollContainer, 'scrollHeight', { value: 1000 }) + Object.defineProperty(scrollContainer, 'clientHeight', { value: 500 }) + Object.defineProperty(scrollContainer, 'scrollTop', { value: 500 }) + + fireEvent.scroll(scrollContainer) + act(() => { + vi.advanceTimersByTime(100) + }) + expect(mockFetchNextPage).toHaveBeenCalled() + } + vi.useRealTimers() + }) + + it('should not trigger fetchNextPage if hasNextPage is false', () => { + vi.useFakeTimers() + vi.mocked(useDatasetList).mockReturnValue({ + data: mockDatasetList, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + } as unknown as ReturnType) + + render() + const selector = screen.getByRole('button', { name: /Test Dataset/i }) + fireEvent.click(selector) + + const menu = screen.getByRole('menu') + const scrollContainer = menu.querySelector('.overflow-auto') + if (scrollContainer) { + Object.defineProperty(scrollContainer, 'scrollHeight', { value: 1000 }) + Object.defineProperty(scrollContainer, 'clientHeight', { value: 500 }) + Object.defineProperty(scrollContainer, 'scrollTop', { value: 500 }) + + fireEvent.scroll(scrollContainer) + act(() => { + vi.advanceTimersByTime(100) + }) + expect(mockFetchNextPage).not.toHaveBeenCalled() + } + vi.useRealTimers() + }) + }) +}) diff --git a/web/app/components/header/env-nav/index.spec.tsx b/web/app/components/header/env-nav/index.spec.tsx new file mode 100644 index 0000000000..2b13af1016 --- /dev/null +++ b/web/app/components/header/env-nav/index.spec.tsx @@ -0,0 +1,52 @@ +import type { AppContextValue } from '@/context/app-context' +import { render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import { useAppContext } from '@/context/app-context' +import EnvNav from './index' + +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +describe('EnvNav', () => { + const mockUseAppContext = vi.mocked(useAppContext) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render null when environment is PRODUCTION', () => { + mockUseAppContext.mockReturnValue({ + langGeniusVersionInfo: { + current_env: 'PRODUCTION', + }, + } as unknown as AppContextValue) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render TESTING tag and icon when environment is TESTING', () => { + mockUseAppContext.mockReturnValue({ + langGeniusVersionInfo: { + current_env: 'TESTING', + }, + } as unknown as AppContextValue) + + render() + expect(screen.getByText('common.environment.testing')).toBeInTheDocument() + }) + + it('should render DEVELOPMENT tag and icon when environment is DEVELOPMENT', () => { + mockUseAppContext.mockReturnValue({ + langGeniusVersionInfo: { + current_env: 'DEVELOPMENT', + }, + } as unknown as AppContextValue) + + render() + expect( + screen.getByText('common.environment.development'), + ).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/explore-nav/index.spec.tsx b/web/app/components/header/explore-nav/index.spec.tsx new file mode 100644 index 0000000000..65a3f88f5e --- /dev/null +++ b/web/app/components/header/explore-nav/index.spec.tsx @@ -0,0 +1,45 @@ +import type { Mock } from 'vitest' +import { render, screen } from '@testing-library/react' +import { useSelectedLayoutSegment } from 'next/navigation' +import ExploreNav from './index' + +vi.mock('next/navigation', () => ({ + useSelectedLayoutSegment: vi.fn(), +})) + +describe('ExploreNav', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render correctly when not active', () => { + (useSelectedLayoutSegment as Mock).mockReturnValue('other') + render() + + const link = screen.getByRole('link') + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', '/explore/apps') + expect(link).toHaveClass('text-components-main-nav-nav-button-text') + expect(link).not.toHaveClass('bg-components-main-nav-nav-button-bg-active') + expect(screen.getByText('common.menus.explore')).toBeInTheDocument() + }) + + it('should render correctly when active', () => { + (useSelectedLayoutSegment as Mock).mockReturnValue('explore') + render() + + const link = screen.getByRole('link') + expect(link).toBeInTheDocument() + expect(link).toHaveClass('bg-components-main-nav-nav-button-bg-active') + expect(link).toHaveClass('text-components-main-nav-nav-button-text-active') + expect(screen.getByText('common.menus.explore')).toBeInTheDocument() + }) + + it('should apply custom className', () => { + (useSelectedLayoutSegment as Mock).mockReturnValue('other') + render() + + const link = screen.getByRole('link') + expect(link).toHaveClass('custom-test-class') + }) +}) diff --git a/web/app/components/header/header-wrapper.spec.tsx b/web/app/components/header/header-wrapper.spec.tsx new file mode 100644 index 0000000000..80ddb14965 --- /dev/null +++ b/web/app/components/header/header-wrapper.spec.tsx @@ -0,0 +1,112 @@ +import { act, render, screen } from '@testing-library/react' +import { usePathname } from 'next/navigation' +import { vi } from 'vitest' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import HeaderWrapper from './header-wrapper' + +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: vi.fn(), +})) + +describe('HeaderWrapper', () => { + type CanvasEvent = { type: string, payload: boolean } + let subscriptionCallback: ((event: CanvasEvent) => void) | null = null + const mockUseSubscription = vi.fn<(callback: (event: CanvasEvent) => void) => void>((callback) => { + subscriptionCallback = callback + }) + + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + subscriptionCallback = null + vi.mocked(usePathname).mockReturnValue('/test') + vi.mocked(useEventEmitterContextContext).mockReturnValue({ + eventEmitter: { useSubscription: mockUseSubscription }, + } as never) + }) + + it('should render children correctly', () => { + render( + +
Test Child
+
, + ) + expect(screen.getByTestId('child')).toBeInTheDocument() + expect(screen.getByText('Test Child')).toBeInTheDocument() + }) + + it('should keep children mounted when workflow maximize events are emitted', () => { + vi.mocked(usePathname).mockReturnValue('/some/path/workflow') + render( + +
Workflow Content
+
, + ) + + act(() => { + subscriptionCallback?.({ type: 'workflow-canvas-maximize', payload: true }) + subscriptionCallback?.({ type: 'workflow-canvas-maximize', payload: false }) + }) + + expect(screen.getByText('Workflow Content')).toBeInTheDocument() + }) + + it('should keep children mounted on pipeline routes when maximize is enabled from storage', () => { + vi.mocked(usePathname).mockReturnValue('/some/path/pipeline') + localStorage.setItem('workflow-canvas-maximize', 'true') + + render( + +
Pipeline Content
+
, + ) + + expect(screen.getByText('Pipeline Content')).toBeInTheDocument() + }) + + it('should keep children mounted on non-canvas routes when maximize is enabled from storage', () => { + vi.mocked(usePathname).mockReturnValue('/apps') + localStorage.setItem('workflow-canvas-maximize', 'true') + + render( + +
App Content
+
, + ) + + expect(screen.getByText('App Content')).toBeInTheDocument() + }) + + it('should keep children mounted when unrelated events are emitted', () => { + vi.mocked(usePathname).mockReturnValue('/some/path/workflow') + render( + +
Workflow Content
+
, + ) + + act(() => { + subscriptionCallback?.({ type: 'other-event', payload: true }) + }) + + expect(screen.getByText('Workflow Content')).toBeInTheDocument() + }) + + it('should render children when eventEmitter is unavailable', () => { + vi.mocked(useEventEmitterContextContext).mockReturnValue({ + eventEmitter: undefined, + } as never) + + render( + +
Content Without Emitter
+
, + ) + + expect(screen.getByText('Content Without Emitter')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/index.spec.tsx b/web/app/components/header/index.spec.tsx new file mode 100644 index 0000000000..2d5ce50fb8 --- /dev/null +++ b/web/app/components/header/index.spec.tsx @@ -0,0 +1,191 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import Header from './index' + +function createMockComponent(testId: string) { + return () =>
+} + +vi.mock('@/app/components/base/logo/dify-logo', () => ({ + default: createMockComponent('dify-logo'), +})) + +vi.mock('@/app/components/header/account-dropdown/workplace-selector', () => ({ + default: createMockComponent('workplace-selector'), +})) + +vi.mock('@/app/components/header/account-dropdown', () => ({ + default: createMockComponent('account-dropdown'), +})) + +vi.mock('@/app/components/header/app-nav', () => ({ + default: createMockComponent('app-nav'), +})) + +vi.mock('@/app/components/header/dataset-nav', () => ({ + default: createMockComponent('dataset-nav'), +})) + +vi.mock('@/app/components/header/env-nav', () => ({ + default: createMockComponent('env-nav'), +})) + +vi.mock('@/app/components/header/explore-nav', () => ({ + default: createMockComponent('explore-nav'), +})) + +vi.mock('@/app/components/header/license-env', () => ({ + default: createMockComponent('license-nav'), +})) + +vi.mock('@/app/components/header/plugins-nav', () => ({ + default: createMockComponent('plugins-nav'), +})) + +vi.mock('@/app/components/header/tools-nav', () => ({ + default: createMockComponent('tools-nav'), +})) + +vi.mock('@/app/components/header/plan-badge', () => ({ + default: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => ( + + ) + } + + const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => { + const context = React.useContext(MenuContext) + if (!context?.open) + return null + return ( + + {children} + + ) + } + + const MenuItem = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => ( + + {children} + + ) + + return { + Menu, + MenuButton, + MenuItems, + MenuItem, + Transition: ({ show = true, children }: { show?: boolean, children: React.ReactNode }) => (show ? <>{children} : null), + } +}) + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useSelectedLayoutSegment: vi.fn(), + useRouter: vi.fn(), +})) + +// Mock app store +vi.mock('@/app/components/app/store', () => ({ + useStore: vi.fn(), +})) + +// Mock app context +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +describe('Nav Component', () => { + const mockSetAppDetail = vi.fn() + const mockOnCreate = vi.fn() + const mockOnLoadMore = vi.fn() + const mockPush = vi.fn() + + const navigationItems: NavItem[] = [ + { + id: '1', + name: 'Item 1', + link: '/item1', + icon_type: 'image', + icon: 'icon1', + icon_background: '#fff', + icon_url: '/url1', + mode: AppModeEnum.CHAT, + }, + { + id: '2', + name: 'Item 2', + link: '/item2', + icon_type: 'image', + icon: 'icon2', + icon_background: '#000', + icon_url: '/url2', + }, + ] + + const defaultProps = { + icon: Icon, + activeIcon: Active Icon, + text: 'Nav Text', + activeSegment: 'explore', + link: '/explore', + isApp: false, + navigationItems, + createText: 'Create New', + onCreate: mockOnCreate, + onLoadMore: mockOnLoadMore, + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useSelectedLayoutSegment).mockReturnValue('explore') + vi.mocked(useAppStore).mockReturnValue(mockSetAppDetail) + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceEditor: true, + } as unknown as AppContextValue) + vi.mocked(useRouter).mockReturnValue({ + push: mockPush, + } as unknown as ReturnType) + }) + + describe('Rendering', () => { + it('should render correctly when activated', () => { + render(