import type { StepByStepTourStatePatchPayload, StepByStepTourStateResponse, } from '@dify/contracts/api/console/onboarding/types.gen' import type { ReactNode } from 'react' import type { Mock } from 'vitest' import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types' import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' import type { ICurrentWorkspace, IWorkspace } from '@/models/common' import type { InstalledApp } from '@/models/explore' import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider } from 'jotai' import { queryClientAtom } from 'jotai-tanstack-query' import { Plan } from '@/app/components/billing/type' import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage' import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { stepByStepTourSessionAtom } from '@/app/components/step-by-step-tour/state' import { STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY } from '@/app/components/step-by-step-tour/storage' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { userProfileQueryOptions } from '@/features/account-profile/client' import { usePathname, useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' import { AppModeEnum } from '@/types/app' import { MainNav } from '../index' type StepByStepTourTestUiState = StepByStepTourSessionState & { minimized: boolean } const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface' const activeStackingClassName = 'aria-[current=page]:z-1' const mockTrackEvent = vi.hoisted(() => vi.fn()) const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess } = vi.hoisted(() => ({ mockSwitchWorkspace: vi.fn(), mockToastSuccess: vi.fn(), mockIsAgentV2Enabled: vi.fn(() => true), })) const mockStepByStepTour = vi.hoisted(() => { const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const const createState = ( overrides: Partial = {}, ): StepByStepTourStateResponse => ({ first_workspace_id: 'workspace-1', skipped: false, completed_task_ids: [], manually_enabled_workspace_ids: [], manually_disabled_workspace_ids: [], updated_at: '2026-07-01T00:00:00Z', ...overrides, }) const createUiState = ( overrides: Partial = {}, ): StepByStepTourTestUiState => ({ activeGuideGroup: undefined, activeGuideIndex: undefined, activeGuideIndexes: undefined, activeTaskId: undefined, minimized: false, ...overrides, }) let state = createState() let uiState = createUiState() const patchState = vi.fn( async ({ body, }: { body: StepByStepTourStatePatchPayload }): Promise => { switch (body.action) { case 'enable_current_workspace': state = { ...state, skipped: false, manually_enabled_workspace_ids: Array.from( new Set([...(state.manually_enabled_workspace_ids ?? []), 'workspace-1']), ), manually_disabled_workspace_ids: (state.manually_disabled_workspace_ids ?? []).filter( (id) => id !== 'workspace-1', ), } break case 'disable_current_workspace': state = { ...state, manually_enabled_workspace_ids: (state.manually_enabled_workspace_ids ?? []).filter( (id) => id !== 'workspace-1', ), manually_disabled_workspace_ids: Array.from( new Set([...(state.manually_disabled_workspace_ids ?? []), 'workspace-1']), ), } break case 'skip': state = { ...state, skipped: true, manually_enabled_workspace_ids: (state.manually_enabled_workspace_ids ?? []).filter( (id) => id !== 'workspace-1', ), } break } return state }, ) return { get state() { return state }, get uiState() { return uiState }, patchState, reset() { state = createState() uiState = createUiState() patchState.mockClear() }, setState(overrides: Partial = {}) { state = createState(overrides) }, setUiState(overrides: Partial = {}) { uiState = createUiState(overrides) if (overrides.minimized !== undefined) { localStorage.setItem( STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, overrides.minimized ? 'collapsed' : 'expanded', ) } }, stateQueryKey, } }) const mockConsoleState = vi.hoisted(() => ({ current: undefined as ConsoleStateFixture | undefined, })) vi.mock('@/features/agent-v2/feature-flag', () => ({ isAgentV2Enabled: () => mockIsAgentV2Enabled(), })) vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: mockTrackEvent, })) vi.mock('@/context/account-state', async () => { const { createAccountStateModuleMock } = await import('@/test/console/state-fixture') return createAccountStateModuleMock(() => mockConsoleState.current ?? {}) }) vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') return createWorkspaceStateModuleMock(() => mockConsoleState.current ?? {}) }) vi.mock('@/context/permission-state', async () => { const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') return createPermissionStateModuleMock(() => mockConsoleState.current ?? {}) }) vi.mock('@/context/version-state', async () => { const { createVersionStateModuleMock } = await import('@/test/console/state-fixture') return createVersionStateModuleMock(() => mockConsoleState.current ?? {}) }) vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) vi.mock('@/context/modal-context', () => ({ useModalContext: vi.fn(), useModalContextSelector: (selector: (state: { hasBlockingModalOpen: boolean }) => T) => selector({ hasBlockingModalOpen: false, }), })) vi.mock('@/next/navigation', async (importOriginal) => { const actual = await importOriginal() return { ...actual, usePathname: vi.fn(), useRouter: vi.fn(), } }) vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next') const { createReactI18nextMock } = await import('@/test/i18n-mock') return { ...actual, ...createReactI18nextMock({ 'common.stepByStepTour.title': 'Get to know Dify', 'common.stepByStepTour.duration': 'A quick tour — about 5 minutes', 'common.stepByStepTour.skip': 'Skip tour', 'common.stepByStepTour.minimize': 'Minimize tour', 'common.stepByStepTour.restore': 'Open step-by-step tour', 'common.stepByStepTour.learnMore': 'Learn more', 'common.stepByStepTour.tasks.home.title': 'Try a Learn Dify lesson', 'common.stepByStepTour.tasks.home.description': 'Open a hands-on lesson from Learn Dify to see Dify in action.', 'common.stepByStepTour.tasks.home.primaryActionLabel': 'Show me', 'common.stepByStepTour.tasks.studio.title': 'Manage your apps in Studio', 'common.stepByStepTour.tasks.studio.description': 'All your apps live in Studio — edit, organize, and publish them here.', 'common.stepByStepTour.tasks.studio.primaryActionLabel': 'Take a look', 'common.stepByStepTour.tasks.knowledge.title': 'Add your own data', 'common.stepByStepTour.tasks.knowledge.description': 'Build a knowledge base so your apps answer from your documents.', 'common.stepByStepTour.tasks.knowledge.primaryActionLabel': 'Take a look', 'common.stepByStepTour.tasks.integration.title': 'Explore integrations', 'common.stepByStepTour.tasks.integration.description': 'Models, tools, data sources & more — explore what you can connect.', 'common.stepByStepTour.tasks.integration.primaryActionLabel': 'Take a look', }), } }) vi.mock('@/service/client', async (importOriginal) => { const actual = await importOriginal() const currentWorkspaceQueryKey = ['console', 'workspaces', 'current', 'post'] as const const workspacesQueryKey = ['console', 'workspaces', 'get'] as const const consoleQuery = new Proxy(actual.consoleQuery, { get(target, prop, receiver) { if (prop === 'workspaces') { return { current: { post: { key: () => currentWorkspaceQueryKey, queryKey: () => currentWorkspaceQueryKey, queryOptions: (options?: object) => ({ queryKey: currentWorkspaceQueryKey, queryFn: () => new Promise(() => {}), ...options, }), }, }, get: { queryKey: () => workspacesQueryKey, queryOptions: () => ({ queryKey: workspacesQueryKey, queryFn: () => new Promise(() => {}), }), }, switch: { post: { mutationOptions: () => ({ mutationFn: (variables: unknown) => mockSwitchWorkspace(variables), }), }, }, } } if (prop === 'onboarding') { return { stepByStepTour: { state: { get: { queryKey: () => mockStepByStepTour.stateQueryKey, queryOptions: () => ({ queryKey: mockStepByStepTour.stateQueryKey, queryFn: async () => mockStepByStepTour.state, }), }, patch: { mutationOptions: (options = {}) => ({ mutationFn: mockStepByStepTour.patchState, ...options, }), }, }, }, } } return Reflect.get(target, prop, receiver) }, }) return { ...actual, consoleQuery, } }) vi.mock('@/service/use-explore', () => ({ useGetInstalledApps: vi.fn(), useUninstallApp: vi.fn(), useUpdateAppPinStatus: vi.fn(), })) vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { const actual = await importOriginal() return { ...actual, toast: { ...actual.toast, success: mockToastSuccess, }, } }) vi.mock('@/app/components/header/github-star', () => ({ default: ({ className }: { className?: string }) => 1,234, })) vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, })) vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() return { ...actual, IS_CLOUD_EDITION: true, SUPPORT_EMAIL_ADDRESS: '', ZENDESK_WIDGET_KEY: '', } }) const mockPush = vi.fn() const mockSetShowPricingModal = vi.fn() const mockSetShowAccountSettingModal = vi.fn() const mockUninstall = vi.fn() const mockUpdatePinStatus = vi.fn() let mockPathname = '/apps' let mockInstalledApps: InstalledApp[] = [] let mockInstalledAppsPending = false let mockWorkspaces: IWorkspace[] = [] const ownerWorkspacePermissionKeys = [ 'workspace.member.manage', 'workspace.role.manage', 'app_library.access', 'app.create_and_management', 'dataset.create_and_management', 'dataset.external.connect', 'tool.manage', 'mcp.manage', ] const datasetOperatorWorkspacePermissionKeys = [ 'plugin.install', 'dataset.create_and_management', 'dataset.external.connect', ] const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({ id: overrides.id ?? 'installed-1', uninstallable: overrides.uninstallable ?? false, is_pinned: overrides.is_pinned ?? false, app: { id: overrides.app?.id ?? 'app-1', mode: overrides.app?.mode ?? AppModeEnum.CHAT, icon_type: overrides.app?.icon_type ?? 'emoji', icon: overrides.app?.icon ?? '🤖', icon_background: overrides.app?.icon_background ?? '#fff', icon_url: overrides.app?.icon_url ?? '', name: overrides.app?.name ?? 'Alpha App', description: overrides.app?.description ?? '', use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, }, }) const mainNavUserProfile = { id: 'user-1', name: 'Evan Z', email: 'evan@example.com', avatar: '', avatar_url: '', is_password_set: true, } const consoleState: ConsoleStateFixture = { userProfile: mainNavUserProfile, refreshUserProfile: vi.fn(), currentWorkspace: { id: 'workspace-1', name: 'Solar Studio', plan: Plan.sandbox, status: 'normal', created_at: 0, role: 'owner', providers: [], trial_credits: 10000, trial_credits_used: 2500, next_credit_reset_date: 0, }, isCurrentWorkspaceManager: true, isCurrentWorkspaceOwner: true, isCurrentWorkspaceEditor: true, isCurrentWorkspaceDatasetOperator: false, refreshCurrentWorkspace: vi.fn(), langGeniusVersionInfo: { current_env: 'testing', current_version: '1.0.0', latest_version: '1.0.0', release_date: '', release_notes: '', version: '1.0.0', can_auto_update: false, }, isLoadingCurrentWorkspace: false, isLoadingWorkspacePermissionKeys: false, workspacePermissionKeys: ownerWorkspacePermissionKeys, } type MainNavSystemFeatures = Exclude< NonNullable[1]>['systemFeatures'], null | undefined > const defaultMainNavSystemFeatures: MainNavSystemFeatures = { branding: { enabled: false }, enable_marketplace: true, enable_step_by_step_tour: true, } const renderMainNav = ( systemFeatures: MainNavSystemFeatures = defaultMainNavSystemFeatures, options: { store?: ReturnType; extra?: ReactNode } = {}, ) => { const queryClient = createConsoleQueryClient() const currentConsoleState = mockConsoleState.current ?? consoleState mockConsoleState.current = currentConsoleState queryClient.setQueryData( consoleQuery.workspaces.current.post.queryKey(), currentConsoleState.currentWorkspace as ICurrentWorkspace, ) queryClient.setQueryData(userProfileQueryOptions().queryKey, { profile: { ...mainNavUserProfile, ...(currentConsoleState.userProfile ?? {}), }, meta: { currentVersion: null, currentEnv: null, }, }) queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces }) queryClient.setQueryData(mockStepByStepTour.stateQueryKey, mockStepByStepTour.state) const store = options.store ?? createStore() seedRegisteredConsoleStateFixture(store) store.set(queryClientAtom, queryClient) store.set(stepByStepTourSessionAtom, mockStepByStepTour.uiState) const resolvedSystemFeatures = { ...defaultMainNavSystemFeatures, ...systemFeatures, branding: { ...defaultMainNavSystemFeatures.branding, ...systemFeatures.branding, }, } return renderWithConsoleQuery( {options.extra} , { systemFeatures: resolvedSystemFeatures, queryClient }, ) } describe('MainNav', () => { beforeEach(() => { vi.clearAllMocks() gotoAnythingDialogHandle.close() localStorage.clear() mockPathname = '/apps' mockInstalledApps = [] mockInstalledAppsPending = false mockWorkspaces = [ { id: 'workspace-1', name: 'Solar Studio', plan: Plan.team, status: 'normal', created_at: 0, current: true, }, { id: 'workspace-2', name: 'Evan Workspace', plan: Plan.sandbox, status: 'normal', created_at: 0, current: false, }, ] mockStepByStepTour.reset() mockIsAgentV2Enabled.mockReturnValue(true) ;(usePathname as Mock).mockImplementation(() => mockPathname) ;(useRouter as Mock).mockReturnValue({ push: mockPush, replace: vi.fn(), prefetch: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), }) mockConsoleState.current = consoleState ;(useProviderContext as Mock).mockReturnValue({ enableBilling: true, isEducationAccount: false, isEducationWorkspace: false, isFetchedPlan: true, plan: { type: Plan.sandbox }, } as ProviderContextState) ;(useModalContext as Mock).mockReturnValue({ setShowPricingModal: mockSetShowPricingModal, setShowAccountSettingModal: mockSetShowAccountSettingModal, } as unknown as ModalContextState) ;(useGetInstalledApps as Mock).mockImplementation(() => ({ isPending: mockInstalledAppsPending, data: { installed_apps: mockInstalledApps }, })) ;(useUninstallApp as Mock).mockReturnValue({ mutateAsync: mockUninstall, isPending: false, }) ;(useUpdateAppPinStatus as Mock).mockReturnValue({ mutateAsync: mockUpdatePinStatus, }) mockSwitchWorkspace.mockReturnValue(new Promise(() => {})) }) it('renders primary navigation with the planned routes', () => { renderMainNav() expect(screen.getAllByText(Plan.team)).toHaveLength(1) expect(screen.getByRole('button', { name: 'common.account.account' })).not.toHaveTextContent( Plan.team, ) expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/') expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps') expect(screen.getByRole('link', { name: /Agents/ })).toHaveAttribute('href', '/agents') expect(screen.getByRole('link', { name: /Agents common.menus.status/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute( 'href', '/datasets', ) expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute( 'href', '/integrations/model-provider', ) expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute( 'href', '/marketplace', ) }) it('hides the roster entry when Agent v2 is disabled', () => { mockIsAgentV2Enabled.mockReturnValue(false) renderMainNav() expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() }) it('hides the marketplace entry when marketplace is disabled', () => { renderMainNav({ enable_marketplace: false }) expect( screen.queryByRole('link', { name: /common.mainNav.marketplace/ }), ).not.toBeInTheDocument() }) it('renders deployments in primary navigation when app deploy is enabled', () => { renderMainNav({ branding: { enabled: false }, enable_app_deploy: true }) const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) const deploymentsLink = screen.getByRole('link', { name: /common.menus.deployments/ }) expect(deploymentsLink).toHaveAttribute('href', '/deployments') expect(marketplaceLink.compareDocumentPosition(deploymentsLink)).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ) }) it('hides deployments in primary navigation when app deploy is disabled', () => { renderMainNav({ branding: { enabled: false }, enable_app_deploy: false }) expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument() }) it('aligns the global navigation spacing with the main sidebar design', async () => { mockInstalledApps = [createInstalledApp()] const { container } = renderMainNav() const mainNav = container.querySelector('aside') expect(mainNav).toHaveClass('w-62', 'p-1') expect(mainNav?.firstElementChild).toHaveClass('w-60') const logoLink = screen.getByLabelText('Dify') expect(logoLink).not.toHaveClass('px-2') expect(logoLink.parentElement).toHaveClass('pt-3', 'pr-2', 'pb-2', 'pl-4') const homeLink = screen.getByRole('link', { name: /common.mainNav.home/ }) expect(homeLink.closest('nav')).toHaveClass('isolate', 'flex', 'flex-col', 'gap-px', 'p-2') expect(homeLink).toHaveClass('h-8', 'w-full', 'rounded-[10px]', 'px-2', 'py-1.5') const webAppsButton = await screen.findByRole('button', { name: 'explore.sidebar.webApps' }) expect(webAppsButton.parentElement).toHaveClass('py-1', 'pr-2', 'pl-2') const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }) expect(helpButton.parentElement?.parentElement).toHaveClass('w-60') expect(helpButton.parentElement?.parentElement).not.toHaveClass('w-full') expect(helpButton.parentElement).toHaveClass('shrink-0', 'rounded-full', 'p-1') }) it('keeps the global navigation account section expanded on home routes', () => { localStorage.setItem(DETAIL_SIDEBAR_STORAGE_KEY, 'collapse') mockPathname = '/' renderMainNav() const accountButton = screen.getByRole('button', { name: 'common.account.account' }) expect(accountButton).toHaveTextContent('Evan Z') expect(accountButton).toHaveClass('max-w-[180px]', 'gap-3', 'py-1', 'pr-4', 'pl-1') expect(accountButton).not.toHaveClass('justify-center', 'p-1') }) it('renders the desktop environment tag from the old header contract', () => { mockConsoleState.current = { ...consoleState, langGeniusVersionInfo: { ...consoleState.langGeniusVersionInfo, current_env: 'TESTING', }, } renderMainNav() const environmentTag = screen.getByText('common.environment.testing') expect(environmentTag).toBeInTheDocument() expect(environmentTag.closest('.relative.z-30')).toHaveClass('mt-auto', 'shrink-0') }) it('does not reserve environment tag space when the environment is not shown', () => { const { container } = renderMainNav() expect(screen.queryByText('common.environment.testing')).not.toBeInTheDocument() expect(screen.queryByText('common.environment.development')).not.toBeInTheDocument() expect(container.querySelector('.relative.z-30')).not.toBeInTheDocument() }) it('shows the user education badge in the account popup without adding the workspace plan there', async () => { ;(useProviderContext as Mock).mockReturnValue({ enableBilling: true, isEducationAccount: true, isEducationWorkspace: false, isFetchedPlan: true, plan: { type: Plan.sandbox }, } as ProviderContextState) renderMainNav() fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) expect(await screen.findByText('EDU')).toBeInTheDocument() expect(screen.getByText('evan@example.com')).toBeInTheDocument() expect(screen.getAllByText(Plan.team)).toHaveLength(1) }) it('keeps unrestricted main routes visible for dataset operators while hiding roster', () => { mockConsoleState.current = { ...consoleState, currentWorkspace: { ...consoleState.currentWorkspace, role: 'dataset_operator', }, isCurrentWorkspaceDatasetOperator: true, isCurrentWorkspaceEditor: false, isCurrentWorkspaceManager: false, isCurrentWorkspaceOwner: false, workspacePermissionKeys: datasetOperatorWorkspacePermissionKeys, } renderMainNav() expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/') expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps') expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute( 'href', '/datasets', ) expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute( 'href', '/integrations/model-provider', ) expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute( 'href', '/marketplace', ) expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument() expect( screen.queryByRole('button', { name: 'explore.sidebar.webApps' }), ).not.toBeInTheDocument() }) it('keeps unrestricted main routes visible without route permission keys', () => { mockConsoleState.current = { ...consoleState, currentWorkspace: { ...consoleState.currentWorkspace, role: 'normal', }, isCurrentWorkspaceDatasetOperator: false, isCurrentWorkspaceEditor: false, isCurrentWorkspaceManager: false, isCurrentWorkspaceOwner: false, workspacePermissionKeys: ['app_library.access', 'tool.manage'], } renderMainNav({ branding: { enabled: false }, enable_app_deploy: true }) expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /common.menus.apps/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /Agents/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toBeInTheDocument() expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument() expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toBeInTheDocument() }) it('marks the matching primary route active', () => { mockPathname = '/datasets' renderMainNav() const datasetsLink = screen.getByRole('link', { name: /common.menus.datasets/ }) expect(datasetsLink).toHaveClass(activeGradientMaskClassName) expect(datasetsLink).toHaveAttribute('aria-current', 'page') expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute( 'aria-current', ) }) it('keeps Studio active on snippets routes', () => { mockPathname = '/snippets' renderMainNav() const studioLink = screen.getByRole('link', { name: /common.menus.apps/ }) expect(studioLink).toHaveClass(activeGradientMaskClassName) expect(studioLink).toHaveAttribute('aria-current', 'page') expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute( 'aria-current', ) }) it('keeps roster detail navigation hidden when Agent v2 is disabled', () => { mockIsAgentV2Enabled.mockReturnValue(false) mockPathname = '/agents/agent-1/configure' renderMainNav() expect(screen.queryByTestId('agent-detail-top')).not.toBeInTheDocument() expect(screen.queryByTestId('agent-detail-section')).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() }) it('keeps MainNav on primary navigation when it is mounted on a detail route', () => { mockPathname = '/app/app-1/overview' renderMainNav() expect(screen.queryByTestId('app-detail-top')).not.toBeInTheDocument() expect(screen.queryByTestId('app-detail-section')).not.toBeInTheDocument() expect( screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), ).toBeInTheDocument() expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps') }) it.each(['/deployments', '/deployments/create'])( 'keeps global navigation on deployment collection route %s', (pathname) => { mockPathname = pathname renderMainNav({ branding: { enabled: false }, enable_app_deploy: true }) expect(screen.queryByTestId('deployment-detail-top')).not.toBeInTheDocument() expect(screen.queryByTestId('deployment-detail-section')).not.toBeInTheDocument() expect( screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), ).toBeInTheDocument() expect(screen.getByRole('link', { name: /common.menus.deployments/ })).toHaveAttribute( 'href', '/deployments', ) }, ) it.each([ '/datasets/create', '/datasets/create-from-pipeline', '/datasets/connect', '/datasets/dataset-1/documents/create', '/datasets/dataset-1/documents/create-from-pipeline', ])('keeps global navigation on dataset creation route %s', (pathname) => { mockPathname = pathname renderMainNav() expect(screen.queryByTestId('dataset-detail-top')).not.toBeInTheDocument() expect(screen.queryByTestId('dataset-detail-section')).not.toBeInTheDocument() expect( screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), ).toBeInTheDocument() expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute( 'href', '/datasets', ) }) it('marks marketplace active on marketplace routes', () => { mockPathname = '/marketplace' renderMainNav() const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) expect(marketplaceLink).toHaveClass(activeGradientMaskClassName) }) it('marks roster active on roster routes', () => { mockPathname = '/agents' renderMainNav() const rosterLink = screen.getByRole('link', { name: /Agents/ }) expect(rosterLink).toHaveClass(activeGradientMaskClassName) expect(rosterLink).toHaveAttribute('aria-current', 'page') }) it('applies the Figma glass active state to the Home route', () => { mockPathname = '/' renderMainNav() const homeLink = screen.getByRole('link', { name: /common.mainNav.home/ }) expect(homeLink).toHaveClass(activeGradientMaskClassName) expect(homeLink).toHaveClass(activeStackingClassName) }) it('keeps Home active on the legacy explore apps route only', () => { mockPathname = '/explore/apps' const { rerender } = renderMainNav() const homeLink = screen.getByRole('link', { name: /common.mainNav.home/ }) expect(homeLink).toHaveAttribute('aria-current', 'page') mockPathname = '/installed/installed-1' rerender() expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute( 'aria-current', ) }) it('opens goto anything from the search button', async () => { renderMainNav(undefined, { extra: ( Goto Anything ), }) fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' })) expect(await screen.findByRole('dialog', { name: 'Goto Anything' })).toBeInTheDocument() }) it('shows Learn Dify switch in help menu and restores it from localStorage', async () => { localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true') renderMainNav({ enable_learn_app: true }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) const learnDifyItem = await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify', }) expect(learnDifyItem).toHaveAttribute('aria-checked', 'false') fireEvent.click(learnDifyItem) await waitFor(() => { expect(localStorage.getItem(LEARN_DIFY_HIDDEN_STORAGE_KEY)).toBe('false') }) expect(mockPush).not.toHaveBeenCalled() }) it('shows Step-by-step Tour switch in help menu and stores the current workspace disable override', async () => { const user = userEvent.setup() renderMainNav({ enable_learn_app: true }) await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) const stepByStepTourItem = await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour', }) expect(stepByStepTourItem).toHaveAttribute('aria-checked', 'true') await user.click(stepByStepTourItem) await waitFor(() => { expect(mockStepByStepTour.patchState.mock.calls[0]?.[0]).toEqual({ body: { action: 'disable_current_workspace' }, }) }) expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument() expect(screen.getByRole('menu')).toBeInTheDocument() expect(mockPush).not.toHaveBeenCalled() expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { action: 'tour_disabled' }) }) it('shows Step-by-step Tour switch off for existing accounts without a default workspace', async () => { const user = userEvent.setup() mockStepByStepTour.setState({ first_workspace_id: null, manually_enabled_workspace_ids: [], manually_disabled_workspace_ids: [], }) renderMainNav({ enable_learn_app: true }) await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) expect( await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour' }), ).toHaveAttribute('aria-checked', 'false') }) it('lets existing accounts enable Step-by-step Tour from the help menu', async () => { const user = userEvent.setup() localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed') mockStepByStepTour.setState({ first_workspace_id: null, manually_enabled_workspace_ids: [], manually_disabled_workspace_ids: [], }) renderMainNav({ enable_learn_app: true }) await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) const stepByStepTourItem = await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour', }) expect(stepByStepTourItem).toHaveAttribute('aria-checked', 'false') await user.click(stepByStepTourItem) await waitFor(() => { expect(mockStepByStepTour.patchState.mock.lastCall?.[0]).toEqual({ body: { action: 'enable_current_workspace' }, }) expect(localStorage.getItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY)).toBe('expanded') }) expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { action: 'tour_enabled' }) await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) expect( await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour' }), ).toHaveAttribute('aria-checked', 'true') }) it('hides Step-by-step Tour switch when the feature is disabled', async () => { const user = userEvent.setup() renderMainNav({ enable_learn_app: true, enable_step_by_step_tour: false, }) await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) await screen.findByText('common.mainNav.help.docs') expect( screen.queryByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour' }), ).not.toBeInTheDocument() }) it('hides Learn Dify switch in help menu when learn app is disabled', async () => { renderMainNav({ enable_learn_app: false }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) await screen.findByText('common.mainNav.help.docs') expect( screen.queryByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' }), ).not.toBeInTheDocument() }) it('orders help menu items to match the nav shell design', async () => { renderMainNav({ enable_learn_app: true }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) const labels = [ 'common.mainNav.help.docs', 'common.userProfile.roadmap', 'common.mainNav.help.learnDify', 'common.mainNav.help.stepByStepTour', 'common.userProfile.compliance', 'common.userProfile.forum', 'common.userProfile.community', 'common.userProfile.github', 'common.userProfile.about', ] const nodes = await Promise.all(labels.map((label) => screen.findByText(label))) nodes.slice(1).forEach((node, index) => { expect(nodes[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) }) }) it('closes the help menu from the support upgrade action', async () => { renderMainNav() fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) const contactUsItem = await screen.findByRole('menuitem', { name: 'common.userProfile.contactUs billing.upgradeBtn.encourageShort', }) expect( screen.queryByRole('button', { name: 'billing.upgradeBtn.encourageShort' }), ).not.toBeInTheDocument() fireEvent.click(contactUsItem) await waitFor(() => { expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument() }) expect(mockSetShowPricingModal).toHaveBeenCalled() }) it('hides the help menu when branding is enabled', () => { renderMainNav({ branding: { enabled: true } }) expect( screen.queryByRole('button', { name: 'common.mainNav.help.openMenu' }), ).not.toBeInTheDocument() }) it('opens workspace settings, members, plan, and workspace switching actions', async () => { renderMainNav() expect( screen.getByRole('link', { name: /common\.mainNav\.workspace\.credits|7,500 credits/ }), ).toHaveAttribute('href', '/integrations/model-provider') expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER, }) fireEvent.click(screen.getByText('billing.upgradeBtn.plain')) expect(mockSetShowPricingModal).toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) fireEvent.click(await screen.findByText('common.mainNav.workspace.settings')) expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.BILLING, }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) fireEvent.click(await screen.findByText('common.mainNav.workspace.inviteMembers')) expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.MEMBERS, }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) fireEvent.click(await screen.findByText('Evan Workspace')) await waitFor(() => { expect(mockSwitchWorkspace).toHaveBeenCalledWith({ body: { tenant_id: 'workspace-2' } }) }) }) it('shows the upgrade shortcut for sandbox workspaces', () => { mockWorkspaces = [ { id: 'workspace-1', name: 'Solar Studio', plan: Plan.sandbox, status: 'normal', created_at: 0, current: true, }, ] renderMainNav() expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.plain')).not.toBeInTheDocument() }) it('shows the view plan shortcut for paid workspaces', () => { ;(useProviderContext as Mock).mockReturnValue({ enableBilling: true, isEducationAccount: false, isEducationWorkspace: false, isFetchedPlan: true, plan: { type: Plan.team }, } as ProviderContextState) renderMainNav() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() fireEvent.click(screen.getByText('billing.upgradeBtn.plain')) expect(mockSetShowPricingModal).toHaveBeenCalled() expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.BILLING, }) }) it('limits invite members by member management permission', async () => { mockConsoleState.current = { ...consoleState, currentWorkspace: { ...consoleState.currentWorkspace, role: 'normal', }, isCurrentWorkspaceManager: false, isCurrentWorkspaceOwner: false, workspacePermissionKeys: ownerWorkspacePermissionKeys.filter( (key) => key !== 'workspace.member.manage', ), } renderMainNav() fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) expect(await screen.findByText('common.mainNav.workspace.settings')).toBeInTheDocument() expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument() }) it('keeps workspace settings visible and hides invite members without member management permission', () => { mockConsoleState.current = { ...consoleState, currentWorkspace: { ...consoleState.currentWorkspace, role: 'dataset_operator', }, isCurrentWorkspaceDatasetOperator: true, isCurrentWorkspaceEditor: false, isCurrentWorkspaceManager: false, isCurrentWorkspaceOwner: false, workspacePermissionKeys: datasetOperatorWorkspacePermissionKeys, } renderMainNav() fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) expect(screen.getByText('common.mainNav.workspace.settings')).toBeInTheDocument() expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument() }) it('filters installed web apps and renders installed app navigation link', () => { mockInstalledApps = [ createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: 'Alpha App' }, }), createInstalledApp({ id: 'installed-2', app: { ...createInstalledApp().app, name: 'Beta Tool' }, }), ] renderMainNav() fireEvent.click(screen.getByRole('button', { name: 'common.operation.search' })) fireEvent.change(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), { target: { value: 'beta' }, }) expect(screen.queryByText('Alpha App')).not.toBeInTheDocument() expect(screen.getByText('Beta Tool')).toBeInTheDocument() expect( screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' }), ).toHaveAttribute('href', '/installed/installed-2') }) it('renders web app skeleton rows while installed apps are loading', () => { mockInstalledAppsPending = true renderMainNav() expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toHaveAttribute( 'aria-busy', 'true', ) expect( screen.queryByRole('button', { name: 'explore.sidebar.webApps' }), ).not.toBeInTheDocument() expect( screen.queryByRole('button', { name: 'common.operation.search' }), ).not.toBeInTheDocument() expect(screen.queryByText('common.loading')).not.toBeInTheDocument() expect(screen.queryByText('Alpha App')).not.toBeInTheDocument() }) it('hides the installed web apps section when no web apps are available', () => { renderMainNav() expect( screen.queryByRole('button', { name: 'explore.sidebar.webApps' }), ).not.toBeInTheDocument() expect( screen.queryByRole('region', { name: 'explore.sidebar.webApps' }), ).not.toBeInTheDocument() expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument() expect( screen.queryByRole('button', { name: 'common.operation.search' }), ).not.toBeInTheDocument() }) it('separates pinned and unpinned installed web apps', () => { mockInstalledApps = [ createInstalledApp({ id: 'installed-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' }, }), createInstalledApp({ id: 'installed-2', is_pinned: false, app: { ...createInstalledApp().app, name: 'Unpinned App' }, }), ] renderMainNav() expect(screen.getByText('Pinned App')).toBeInTheDocument() expect(screen.getByText('Unpinned App')).toBeInTheDocument() expect(screen.getByTestId('divider')).toBeInTheDocument() }) it('keeps long installed web app names truncated in the main nav item', () => { const longName = 'A very long installed web app name that should stay on one line and truncate' mockInstalledApps = [ createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: longName }, }), ] renderMainNav() expect(screen.getByText(longName)).toHaveClass('truncate') }) it('virtualizes large installed web app lists', async () => { const offsetHeightSpy = vi .spyOn(HTMLElement.prototype, 'offsetHeight', 'get') .mockReturnValue(320) const offsetWidthSpy = vi .spyOn(HTMLElement.prototype, 'offsetWidth', 'get') .mockReturnValue(240) mockInstalledApps = Array.from({ length: 100 }, (_, index) => createInstalledApp({ id: `installed-${index}`, app: { ...createInstalledApp().app, id: `app-${index}`, name: `Web App ${index}`, }, }), ) try { renderMainNav() expect(await screen.findByText('Web App 0')).toBeInTheDocument() expect(screen.queryByText('Web App 99')).not.toBeInTheDocument() } finally { offsetHeightSpy.mockRestore() offsetWidthSpy.mockRestore() } }) it('collapses and expands installed web apps from the section arrow', async () => { mockInstalledApps = [createInstalledApp()] renderMainNav() const webAppsButton = await screen.findByRole('button', { name: 'explore.sidebar.webApps' }) expect(webAppsButton).toHaveAttribute('aria-expanded', 'true') expect(screen.getByText('Alpha App')).toBeInTheDocument() fireEvent.click(webAppsButton) expect(webAppsButton).toHaveAttribute('aria-expanded', 'false') expect(screen.queryByText('Alpha App')).not.toBeInTheDocument() fireEvent.click(webAppsButton) expect(webAppsButton).toHaveAttribute('aria-expanded', 'true') expect(screen.getByText('Alpha App')).toBeInTheDocument() }) it('updates pin status and reuses the existing delete confirmation for installed web apps', async () => { mockInstalledApps = [createInstalledApp()] mockUninstall.mockResolvedValue(undefined) mockUpdatePinStatus.mockResolvedValue(undefined) renderMainNav() fireEvent.mouseEnter(screen.getByText('Alpha App')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) await waitFor(() => { expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'installed-1', isPinned: true }) }) fireEvent.mouseEnter(screen.getByText('Alpha App')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) fireEvent.click(await screen.findByText('common.operation.confirm')) await waitFor(() => { expect(mockUninstall).toHaveBeenCalledWith('installed-1') expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove') }) }) })