import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen' import type { StepByStepTourStatePatchPayload, StepByStepTourStateResponse, } from '@dify/contracts/api/console/onboarding/types.gen' import type { GetVersionResponse } from '@dify/contracts/api/console/version/types.gen' import type { GetWorkspacesCurrentSummaryResponse, TenantListItemResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' import type { Mock } from 'vite-plus/test' 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 { UserProfileWithMeta } from '@/features/account-profile/client' import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { act, fireEvent, screen, waitFor, within } 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 { 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, stepByStepTourSkipRecoveryVisibleAtom, } 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 { 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 { mockFetchNextInstalledAppsPage, mockInstalledAppsRequest, mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess, mockUninstall, mockUpdatePinStatus, } = vi.hoisted(() => ({ mockFetchNextInstalledAppsPage: vi.fn(), mockInstalledAppsRequest: vi.fn(), mockIsAgentV2Enabled: vi.fn(() => true), mockSwitchWorkspace: vi.fn(), mockToastSuccess: vi.fn(), mockUninstall: vi.fn(), mockUpdatePinStatus: vi.fn(), })) 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, } }) type MainNavConsoleState = ConsoleStateFixture & { profileMeta: UserProfileWithMeta['meta'] versionData: GetVersionResponse } const mockConsoleState = vi.hoisted(() => ({ current: undefined as MainNavConsoleState | undefined, })) const mockProviderContextState = vi.hoisted(() => ({ current: { enableSkill: true, } as Partial, })) vi.mock('@tanstack/react-virtual') vi.mock('@/features/agent-v2/feature-flag', () => ({ isAgentV2Enabled: () => mockIsAgentV2Enabled(), })) vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: mockTrackEvent, })) 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/provider-context', () => ({ useProviderContext: vi.fn(), useProviderContextSelector: vi.fn((selector: (state: Partial) => unknown) => selector(mockProviderContextState.current), ), })) 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.skipRecovery.label': 'Step-by-step Tour recovery tip', 'common.stepByStepTour.skipRecovery.message': 'Tour hidden. Turn it back on anytime in Help → Step-by-step Tour.', 'common.stepByStepTour.skipRecovery.dismiss': 'Got it', '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', 'summary', 'get'] as const const currentPermissionsQueryKey = [ ['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'], { type: 'query' }, ] as const const workspacesQueryKey = ['console', 'workspaces', 'get'] as const const consoleQuery = new Proxy(actual.consoleQuery, { get(target, prop, receiver) { if (prop === 'workspaces') { return { current: { summary: { get: { key: () => currentWorkspaceQueryKey, queryKey: () => currentWorkspaceQueryKey, queryOptions: (options?: object) => ({ queryKey: currentWorkspaceQueryKey, queryFn: () => new Promise(() => {}), ...options, }), }, }, rbac: { myPermissions: { get: { queryOptions: () => ({ queryKey: currentPermissionsQueryKey, queryFn: () => new Promise(() => {}), }), }, }, }, }, 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, }), }, }, }, } } if (prop === 'installedApps') { return { get: { infiniteOptions: (options: { getNextPageParam: (page: { has_more: boolean next_cursor: string | null }) => string | undefined initialPageParam: undefined input: (pageParam: string | undefined) => { query: { cursor?: string; limit: number; name?: string } } placeholderData?: unknown select?: (data: unknown) => unknown }) => ({ ...options, queryKey: ['installed-apps', options.input(undefined).query.name ?? ''], queryFn: ({ pageParam }: { pageParam: string | undefined }) => mockInstalledAppsRequest(options.input(pageParam)), }), }, byInstalledAppId: { delete: { mutationOptions: () => ({ mutationFn: (input: unknown) => mockUninstall(input), }), }, patch: { mutationOptions: () => ({ mutationFn: (input: unknown) => mockUpdatePinStatus(input), }), }, }, } } return Reflect.get(target, prop, receiver) }, }) return { ...actual, consoleQuery, } }) 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('@/next/dynamic', async () => { const { default: WebAppsSection } = await import('../components/web-apps-section') return { default: () => WebAppsSection, } }) vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() return { ...actual, SUPPORT_EMAIL_ADDRESS: '', ZENDESK_WIDGET_KEY: '', } }) const mockPush = vi.fn() const mockSetShowPricingModal = vi.fn() const mockSetSettingsDestination = vi.fn() vi.mock('nuqs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } }) let mockPathname = '/apps' let mockInstalledApps: InstalledAppResponse[] = [] let mockInstalledAppsPending = false let mockInstalledAppsHasNextPage = false let mockWorkspaces: TenantListItemResponse[] = [] function stubScrollRootIntersectionObserver() { const observers: Array<{ callback: IntersectionObserverCallback root: Element | Document | null | undefined }> = [] vi.stubGlobal( 'IntersectionObserver', class MockIntersectionObserver { constructor(nextCallback: IntersectionObserverCallback, options?: IntersectionObserverInit) { observers.push({ callback: nextCallback, root: options?.root }) } observe() {} unobserve() {} disconnect() {} }, ) return async () => { await waitFor(() => { expect(observers.some(({ root }) => root instanceof Element)).toBe(true) }) const observer = observers.find(({ root }) => root instanceof Element) if (!observer) throw new Error('The scroll root observer was not created') act(() => { observer.callback( [{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver, ) }) } } 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', 'agent.manage', 'skill.view', ] const datasetOperatorWorkspacePermissionKeys = [ 'plugin.install', 'dataset.create_and_management', 'dataset.external.connect', ] const createInstalledApp = ( overrides: Partial = {}, ): InstalledAppResponse => ({ id: overrides.id ?? 'installed-1', app_owner_tenant_id: overrides.app_owner_tenant_id ?? 'tenant-1', editable: overrides.editable ?? true, last_used_at: overrides.last_used_at ?? null, 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 ?? null, 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 accountMenuAccessibleName = `${mainNavUserProfile.name} common.account.account` const consoleState: MainNavConsoleState = { userProfile: mainNavUserProfile, currentWorkspace: { id: 'workspace-1', name: 'Solar Studio', plan: 'team', credits: 7500, role: 'owner', }, isCurrentWorkspaceManager: true, isCurrentWorkspaceOwner: true, isCurrentWorkspaceEditor: true, isCurrentWorkspaceDatasetOperator: false, refreshCurrentWorkspace: vi.fn(), profileMeta: { currentEnv: 'testing', currentVersion: '1.0.0', }, versionData: { version: '1.0.0', release_notes: '', }, isLoadingCurrentWorkspace: false, isLoadingWorkspacePermissionKeys: false, workspacePermissionKeys: ownerWorkspacePermissionKeys, } const workspaceMenuAccessibleName = /Solar Studio.*common\.mainNav\.workspace\.openMenu/ type MainNavSystemFeatures = Exclude< NonNullable[1]>['systemFeatures'], null | undefined > const defaultMainNavSystemFeatures: MainNavSystemFeatures = { deployment_edition: 'CLOUD', branding: { enabled: false }, enable_marketplace: true, enable_step_by_step_tour: true, } const renderMainNav = ( systemFeatures: MainNavSystemFeatures = defaultMainNavSystemFeatures, options: { store?: ReturnType extra?: ReactNode educationStatus?: NonNullable[1]>['educationStatus'] skipRecoveryVisible?: boolean } = {}, ) => { const queryClient = createConsoleQueryClient() const currentConsoleState = mockConsoleState.current ?? consoleState mockConsoleState.current = currentConsoleState queryClient.setQueryData( consoleQuery.workspaces.current.summary.get.queryKey(), currentConsoleState.currentWorkspace as GetWorkspacesCurrentSummaryResponse, ) queryClient.setQueryData(userProfileQueryOptions().queryKey, { profile: { ...mainNavUserProfile, ...(currentConsoleState.userProfile ?? {}), }, meta: { currentVersion: currentConsoleState.profileMeta.currentVersion, currentEnv: currentConsoleState.profileMeta.currentEnv, }, }) const currentVersion = currentConsoleState.profileMeta.currentVersion if (currentVersion) { queryClient.setQueryData( consoleQuery.version.get.queryOptions({ input: { query: { current_version: currentVersion } }, }).queryKey, currentConsoleState.versionData, ) } 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) if (options.skipRecoveryVisible !== undefined) store.set(stepByStepTourSkipRecoveryVisibleAtom, options.skipRecoveryVisible) const resolvedSystemFeatures = { ...defaultMainNavSystemFeatures, ...systemFeatures, branding: { ...defaultMainNavSystemFeatures.branding, ...systemFeatures.branding, }, } return renderWithConsoleQuery( {options.extra} , { systemFeatures: resolvedSystemFeatures, educationStatus: options.educationStatus, workspacePermissionKeys: currentConsoleState.workspacePermissionKeys, queryClient, }, ) } describe('MainNav', () => { beforeEach(() => { vi.clearAllMocks() gotoAnythingDialogHandle.close() localStorage.clear() mockPathname = '/apps' mockInstalledApps = [] mockInstalledAppsPending = false mockInstalledAppsHasNextPage = false mockWorkspaces = [ { id: 'workspace-1', name: 'Solar Studio', plan: 'team', status: 'normal', created_at: 0, current: true, }, { id: 'workspace-2', name: 'Evan Workspace', 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 mockProviderContextState.current = { enableSkill: true, } ;(useProviderContext as Mock).mockReturnValue({ enableBilling: true, enableEducationPlan: false, isFetchedPlan: true, plan: { type: 'sandbox' }, } as ProviderContextState) ;(useModalContext as Mock).mockReturnValue({ setShowPricingModal: mockSetShowPricingModal, } as unknown as ModalContextState) mockInstalledAppsRequest.mockImplementation( async ({ query }: { query: { cursor?: string; name?: string } }) => { if (mockInstalledAppsPending) return new Promise(() => {}) if (query.cursor) { mockFetchNextInstalledAppsPage(query.cursor) return { installed_apps: [], has_more: false, next_cursor: null } } const installedApps = query.name ? mockInstalledApps.filter((installedApp) => installedApp.app.name.toLowerCase().includes(query.name!.toLowerCase()), ) : mockInstalledApps return { installed_apps: installedApps, has_more: mockInstalledAppsHasNextPage, next_cursor: mockInstalledAppsHasNextPage ? 'next-page' : null, } }, ) mockUninstall.mockResolvedValue(undefined) mockUpdatePinStatus.mockResolvedValue({ result: 'success', message: 'updated' }) mockSwitchWorkspace.mockReturnValue(new Promise(() => {})) }) it('renders primary navigation with the planned routes', () => { renderMainNav() expect(screen.getAllByText('team')).toHaveLength(1) expect(screen.getByRole('button', { name: accountMenuAccessibleName })).not.toHaveTextContent( 'team', ) const homeLink = screen.getByRole('link', { name: /common.mainNav.home/ }) expect(homeLink).toHaveAttribute('href', '/') expect(homeLink.querySelector('.i-custom-vender-main-nav-home-v2')).toBeInTheDocument() const studioLink = screen.getByRole('link', { name: /common.menus.apps/ }) expect(studioLink).toHaveAttribute('href', '/apps') expect(studioLink.querySelector('.i-custom-vender-main-nav-studio-v2')).toBeInTheDocument() const agentsLink = screen.getByRole('link', { name: /Agents/ }) expect(agentsLink).toHaveAttribute('href', '/agents') expect(screen.getByRole('link', { name: /Agents common.menus.status/ })).toBeInTheDocument() expect(agentsLink.querySelector('.i-custom-vender-main-nav-agent')).toBeInTheDocument() const skillsLink = screen.getByRole('link', { name: /common.mainNav.skills/ }) expect(skillsLink).toHaveAttribute('href', '/skills') expect(skillsLink.querySelector('.i-custom-vender-main-nav-skill')).toBeInTheDocument() const knowledgeLink = screen.getByRole('link', { name: /common.menus.datasets/ }) expect(knowledgeLink).toHaveAttribute('href', '/datasets') expect( knowledgeLink.querySelector('.i-custom-vender-main-nav-knowledge-v2'), ).toBeInTheDocument() const integrationsLink = screen.getByRole('link', { name: /common.mainNav.integrations/ }) expect(integrationsLink).toHaveAttribute('href', '/integrations/model-provider') expect( integrationsLink.querySelector('.i-custom-vender-main-nav-integrations-v2'), ).toBeInTheDocument() const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) expect(marketplaceLink).toHaveAttribute('href', '/marketplace') expect( marketplaceLink.querySelector('.i-custom-vender-main-nav-marketplace-v2'), ).toBeInTheDocument() expect( within(screen.getByRole('navigation')) .getAllByRole('link') .map((link) => link.getAttribute('href')), ).toEqual([ '/', '/apps', '/agents', '/datasets', '/skills', '/integrations/model-provider', '/marketplace', ]) }) it('hides the roster entry when Agent v2 is disabled', () => { mockIsAgentV2Enabled.mockReturnValue(false) renderMainNav() expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() expect(screen.getByRole('link', { name: /common.mainNav.skills/ })).toHaveAttribute( 'href', '/skills', ) }) it('hides the roster entry when the user lacks agent.manage', () => { mockConsoleState.current = { ...consoleState, workspacePermissionKeys: ownerWorkspacePermissionKeys.filter((key) => key !== 'agent.manage'), } renderMainNav() expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() }) it('shows the roster entry when the user has agent.manage', () => { renderMainNav() expect(screen.getByRole('link', { name: /Agents/ })).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('hides the skills entry when skill is disabled', () => { mockProviderContextState.current = { enableSkill: false, } renderMainNav() expect(screen.queryByRole('link', { name: /common.mainNav.skills/ })).not.toBeInTheDocument() }) it('hides the skills entry when the user lacks skill.view', () => { mockConsoleState.current = { ...consoleState, workspacePermissionKeys: ownerWorkspacePermissionKeys.filter((key) => key !== 'skill.view'), } renderMainNav() expect(screen.queryByRole('link', { name: /common.mainNav.skills/ })).not.toBeInTheDocument() }) it('orders the Step-by-step Tour before the account and help actions', async () => { localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed') renderMainNav() const tourTrigger = await screen.findByRole('button', { name: 'Open step-by-step tour' }) const accountButton = screen.getByRole('button', { name: accountMenuAccessibleName }) const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }) expect(tourTrigger.compareDocumentPosition(accountButton)).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ) expect(accountButton.compareDocumentPosition(helpButton)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) }) 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: accountMenuAccessibleName }) expect(accountButton).toHaveTextContent('Evan Z') expect(accountButton).toHaveClass('max-w-45', 'gap-3', 'py-1', 'pr-4', 'pl-1') expect(accountButton).not.toHaveClass('justify-center', 'p-1') }) 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, enableEducationPlan: true, isFetchedPlan: true, plan: { type: 'sandbox' }, } as ProviderContextState) renderMainNav(defaultMainNavSystemFeatures, { educationStatus: { is_student: true }, }) fireEvent.click(screen.getByRole('button', { name: accountMenuAccessibleName })) expect(await screen.findByText('EDU')).toBeInTheDocument() expect(screen.getByText('evan@example.com')).toBeInTheDocument() expect(screen.getAllByText('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.queryByRole('link', { name: /common.mainNav.skills/ })).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('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', 'agent.manage'], } renderMainNav({ branding: { enabled: false } }) 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.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: workspaceMenuAccessibleName })).toBeInTheDocument() expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps') }) 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: workspaceMenuAccessibleName })).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('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 }) const helpTrigger = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }) expect(helpTrigger).not.toHaveAttribute('data-popup-open') fireEvent.click(helpTrigger) expect(helpTrigger).toHaveAttribute('data-popup-open', '') 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(screen.getByRole('menu')).toBeInTheDocument() expect(mockPush).not.toHaveBeenCalled() }) it('keeps focus in the help menu when it dismisses the recovery prompt', async () => { const user = userEvent.setup() mockStepByStepTour.setState({ skipped: true }) renderMainNav(undefined, { skipRecoveryVisible: true }) expect( await screen.findByRole('dialog', { name: 'Step-by-step Tour recovery tip' }), ).toBeInTheDocument() const helpTrigger = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }) await user.click(helpTrigger) expect( screen.queryByRole('dialog', { name: 'Step-by-step Tour recovery tip' }), ).not.toBeInTheDocument() await waitFor(() => { expect(screen.getByRole('menu')).toHaveFocus() }) }) 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('closes the help menu and opens Step-by-step Tour when enabling it', 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 waitFor(() => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) expect(await screen.findByRole('dialog', { name: 'Get to know Dify' })).toBeInTheDocument() 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', 'Discord', '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('opens About from its real Help menu owner and restores focus when closed', async () => { const user = userEvent.setup() mockConsoleState.current = { ...consoleState, versionData: { version: '1.1.0', release_notes: 'https://github.com/langgenius/dify/releases/tag/1.1.0', }, } renderMainNav() const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }) await user.click(helpButton) await user.click(await screen.findByRole('menuitem', { name: /common\.userProfile\.about/ })) expect( await screen.findByRole('dialog', { name: 'common.userProfile.about' }), ).toBeInTheDocument() expect(screen.queryByRole('menu')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.close' })).toHaveFocus() expect(screen.getByRole('link', { name: 'Privacy Policy' })).toHaveAttribute( 'href', 'https://dify.ai/privacy', ) expect(screen.getByRole('link', { name: 'common.about.changeLog' })).toHaveAttribute( 'href', 'https://github.com/langgenius/dify/releases', ) expect(screen.getByRole('link', { name: 'common.about.updateNow' })).toHaveAttribute( 'href', 'https://github.com/langgenius/dify/releases/tag/1.1.0', ) expect(screen.queryByRole('button', { name: 'common.about.changeLog' })).not.toBeInTheDocument() await user.keyboard('{Escape}') await waitFor(() => { expect( screen.queryByRole('dialog', { name: 'common.userProfile.about' }), ).not.toBeInTheDocument() expect(helpButton).toHaveFocus() }) }) it('shows the open-source license in About for non-Cloud editions', async () => { const user = userEvent.setup() renderMainNav({ deployment_edition: 'COMMUNITY' }) await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) await user.click(await screen.findByRole('menuitem', { name: /common\.userProfile\.about/ })) expect(await screen.findByRole('link', { name: 'Open Source License' })).toHaveAttribute( 'href', 'https://github.com/langgenius/dify/blob/main/LICENSE', ) expect(screen.queryByRole('link', { name: 'Privacy Policy' })).not.toBeInTheDocument() }) 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('Discord')).not.toBeInTheDocument() }) expect(mockSetShowPricingModal).toHaveBeenCalled() }) it('hides the help menu when branding is enabled', () => { renderMainNav({ branding: { enabled: true } }, { skipRecoveryVisible: true }) expect( screen.queryByRole('button', { name: 'common.mainNav.help.openMenu' }), ).not.toBeInTheDocument() expect( screen.queryByRole('dialog', { name: 'Step-by-step Tour recovery tip' }), ).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(mockSetSettingsDestination).not.toHaveBeenCalledWith('provider') fireEvent.click(screen.getByText('billing.upgradeBtn.plain')) expect(mockSetShowPricingModal).toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName })) fireEvent.click(await screen.findByText('common.mainNav.workspace.settings')) expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING) fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName })) fireEvent.click(await screen.findByText('common.mainNav.workspace.inviteMembers')) expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.MEMBERS) fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName })) 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', () => { mockConsoleState.current = { ...consoleState, currentWorkspace: { ...consoleState.currentWorkspace, plan: 'sandbox', }, } 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', () => { mockConsoleState.current = { ...consoleState, currentWorkspace: { ...consoleState.currentWorkspace, plan: 'professional', }, } renderMainNav() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() fireEvent.click(screen.getByText('billing.upgradeBtn.plain')) expect(mockSetShowPricingModal).toHaveBeenCalled() expect(mockSetSettingsDestination).not.toHaveBeenCalledWith(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: workspaceMenuAccessibleName })) 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: workspaceMenuAccessibleName })) expect(screen.getByText('common.mainNav.workspace.settings')).toBeInTheDocument() expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument() }) it('searches installed web apps and renders the matching navigation link', async () => { const user = userEvent.setup() mockInstalledApps = [ createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: 'Alpha App' }, }), createInstalledApp({ id: 'installed-2', app: { ...createInstalledApp().app, name: 'Beta Tool' }, }), ] renderMainNav() const scrollViewport = await screen.findByRole('region', { name: 'explore.sidebar.webApps', }) scrollViewport.scrollTop = 240 scrollViewport.scrollTo = (optionsOrX?: ScrollToOptions | number, y?: number) => { const top = typeof optionsOrX === 'object' ? optionsOrX.top : y scrollViewport.scrollTop = Number(top ?? 0) } const searchButton = await screen.findByRole('button', { name: 'common.operation.search' }) expect(searchButton).toHaveAttribute('aria-expanded', 'false') await user.click(searchButton) expect(searchButton).toHaveAttribute('aria-expanded', 'true') const searchInput = screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder') await user.type(searchInput, 'beta') await waitFor(() => { expect(scrollViewport.scrollTop).toBe(0) expect(screen.queryByText('Alpha App')).not.toBeInTheDocument() expect(screen.getByText('Beta Tool')).toBeInTheDocument() }) expect(searchInput).toHaveFocus() expect( screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' }), ).toHaveAttribute('href', '/installed/installed-2') const webAppsButton = screen.getByRole('button', { name: 'explore.sidebar.webApps' }) await user.click(webAppsButton) expect(searchButton).toHaveAttribute('aria-expanded', 'false') expect( screen.queryByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), ).not.toBeInTheDocument() await user.click(webAppsButton) expect(searchButton).toHaveAttribute('aria-expanded', 'true') expect(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder')).toHaveValue( 'beta', ) }) it('announces no installed web app results only after the search settles', async () => { const user = userEvent.setup() let resolveSearch: (() => void) | undefined const searchPending = new Promise((resolve) => { resolveSearch = resolve }) mockInstalledApps = [createInstalledApp()] mockInstalledAppsRequest.mockImplementation(async ({ query }: { query: { name?: string } }) => { if (!query.name) { return { installed_apps: mockInstalledApps, has_more: false, next_cursor: null, } } await searchPending return { installed_apps: [], has_more: false, next_cursor: null, } }) renderMainNav() const webAppsRegion = await screen.findByRole('region', { name: 'explore.sidebar.webApps', }) await user.click(screen.getByRole('button', { name: 'common.operation.search' })) const resultStatus = within(webAppsRegion).getByRole('status') expect(resultStatus).toBeEmptyDOMElement() await user.type(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), 'z') await waitFor(() => { expect(webAppsRegion).toHaveAttribute('aria-busy', 'true') }) expect(resultStatus).toBeEmptyDOMElement() act(() => { resolveSearch?.() }) await waitFor(() => { expect(webAppsRegion).toHaveAttribute('aria-busy', 'false') expect(resultStatus).toHaveTextContent('common.mainNav.webApps.noResults') }) expect(within(webAppsRegion).getByRole('status')).toBe(resultStatus) }) it('hides the installed web apps section while installed apps are loading', () => { mockInstalledAppsPending = true renderMainNav() expect( screen.queryByRole('region', { name: 'explore.sidebar.webApps' }), ).not.toBeInTheDocument() 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', async () => { renderMainNav() await waitFor(() => { expect( screen.queryByRole('button', { name: 'explore.sidebar.webApps' }), ).not.toBeInTheDocument() expect( screen.queryByRole('region', { name: 'explore.sidebar.webApps' }), ).not.toBeInTheDocument() }) expect( screen.queryByRole('button', { name: 'common.operation.search' }), ).not.toBeInTheDocument() }) it('separates pinned and unpinned installed web apps', async () => { 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(await screen.findByText('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', async () => { 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(await screen.findByText(longName)).toHaveClass('truncate') }) it('fetches the next installed web app page when the bottom sentinel enters the viewport', async () => { const triggerIntersection = stubScrollRootIntersectionObserver() mockInstalledApps = [createInstalledApp()] mockInstalledAppsHasNextPage = true renderMainNav() await screen.findByText('Alpha App') await triggerIntersection() await waitFor(() => { expect(mockFetchNextInstalledAppsPage).toHaveBeenCalledWith('next-page') }) }) it('shows next-page errors at the pagination boundary and retries from there', async () => { const user = userEvent.setup() let nextPageAttempts = 0 let resolveNextPage: (() => void) | undefined const nextPagePending = new Promise((resolve) => { resolveNextPage = resolve }) const triggerIntersection = stubScrollRootIntersectionObserver() mockInstalledApps = [createInstalledApp()] mockInstalledAppsRequest.mockImplementation( async ({ query }: { query: { cursor?: string; name?: string } }) => { if (!query.cursor) { return { installed_apps: mockInstalledApps, has_more: true, next_cursor: 'next-page', } } nextPageAttempts += 1 if (nextPageAttempts === 1) throw new Error('Failed to load the next page') await nextPagePending return { installed_apps: [ createInstalledApp({ id: 'installed-2', app: { ...createInstalledApp().app, name: 'Beta Tool' }, }), ], has_more: false, next_cursor: null, } }, ) renderMainNav() const firstAppLink = await screen.findByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Alpha App"}', }) await triggerIntersection() const paginationError = await screen.findByRole('alert') expect( firstAppLink.compareDocumentPosition(paginationError) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) const retryButton = screen.getByRole('button', { name: 'common.operation.retry' }) const webAppsRegion = screen.getByRole('region', { name: 'explore.sidebar.webApps' }) retryButton.focus() expect(retryButton).toHaveFocus() await user.keyboard('{Enter}') await waitFor(() => { expect(webAppsRegion).toHaveAttribute('aria-busy', 'true') expect(retryButton).toBeInTheDocument() expect(retryButton).toHaveFocus() expect(retryButton).toHaveAttribute('aria-disabled', 'true') }) await user.keyboard('{Enter}') expect(nextPageAttempts).toBe(2) act(() => { resolveNextPage?.() }) expect(await screen.findByText('Beta Tool')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) it('collapses and expands installed web apps from the section arrow', async () => { const user = userEvent.setup() 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() await user.click(webAppsButton) expect(webAppsButton).toHaveAttribute('aria-expanded', 'false') expect(screen.queryByText('Alpha App')).not.toBeInTheDocument() await user.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 () => { const user = userEvent.setup() mockInstalledApps = [createInstalledApp()] mockUninstall.mockResolvedValue(undefined) mockUpdatePinStatus.mockResolvedValue(undefined) renderMainNav() await user.hover(await screen.findByText('Alpha App')) await user.click( screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*Alpha App/ }), ) await user.click(await screen.findByText('explore.sidebar.action.pin')) await waitFor(() => { expect(mockUpdatePinStatus).toHaveBeenCalledWith({ params: { installed_app_id: 'installed-1' }, body: { is_pinned: true }, }) }) await user.hover(screen.getByText('Alpha App')) await user.click( screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*Alpha App/ }), ) await user.click(await screen.findByText('explore.sidebar.action.delete')) await user.click(await screen.findByText('common.operation.confirm')) await waitFor(() => { expect(mockUninstall).toHaveBeenCalledWith({ params: { installed_app_id: 'installed-1' }, }) expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove') }) }) })