// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' const openSessionSearchMock = vi.hoisted(() => vi.fn()) const mockAppStore = vi.hoisted(() => ({ sidebarOpen: true, sidebarCollapsed: false, connected: true, serverVersion: 'test', latestVersion: '', isDocker: false, updateAvailable: false, clientOutdated: false, updating: false, toggleSidebar: vi.fn(), toggleSidebarCollapsed: vi.fn(), closeSidebar: vi.fn(), doUpdate: vi.fn(), reloadClient: vi.fn(), })) vi.mock('@/composables/useSessionSearch', () => ({ useSessionSearch: () => ({ openSessionSearch: openSessionSearchMock, }), })) vi.mock('@/stores/hermes/app', () => ({ useAppStore: () => mockAppStore, })) vi.mock('vue-router', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useRoute: () => ({ name: 'hermes.chat' }), useRouter: () => ({ push: vi.fn(), hasRoute: () => true }), } }) vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, }), createI18n: () => ({ global: { locale: { value: 'en' }, setLocaleMessage: vi.fn() }, }), })) vi.mock('@/composables/useTheme', () => ({ useTheme: () => ({ isDark: false }), })) vi.mock('/logo.png', () => ({ default: 'logo.png', })) vi.mock('@/components/layout/ProfileSelector.vue', () => ({ default: { name: 'ProfileSelector', template: '
' }, })) vi.mock('@/components/layout/ModelSelector.vue', () => ({ default: { name: 'ModelSelector', template: '
' }, })) vi.mock('@/components/layout/LanguageSwitch.vue', () => ({ default: { name: 'LanguageSwitch', template: '
' }, })) vi.mock('@/components/common/RouteLinkItem.vue', () => ({ default: { name: 'RouteLinkItem', props: ['to', 'active'], template: '', }, })) vi.mock('naive-ui', () => ({ // AppSidebar no longer imports naive-ui; keep the module inert for any // transitive import during the Arco migration. useMessage: () => ({ success: vi.fn(), error: vi.fn(), }), })) import AppSidebar from '@/components/layout/AppSidebar.vue' import { config } from '@vue/test-utils' // Arco components are globally registered in main.ts; register test stubs // here with the same contract the sidebar relies on. config.global.stubs = { 'a-button': { template: '', }, 'a-modal': { props: ['visible'], emits: ['update:visible'], template: '
', }, 'a-tag': { template: '', }, } function fakeJwt(payload: Record) { return `header.${btoa(JSON.stringify(payload)).replace(/=/g, '')}.signature` } describe('AppSidebar navigation', () => { beforeEach(() => { localStorage.clear() openSessionSearchMock.mockClear() mockAppStore.serverVersion = 'test' mockAppStore.latestVersion = '' mockAppStore.isDocker = false mockAppStore.updateAvailable = false mockAppStore.clientOutdated = false mockAppStore.updating = false mockAppStore.sidebarCollapsed = false mockAppStore.reloadClient.mockClear() mockAppStore.doUpdate.mockReset() mockAppStore.doUpdate.mockResolvedValue(false) }) it('keeps page-sidebar-only actions out of the app sidebar', () => { const wrapper = mount(AppSidebar, { global: { stubs: { ProfileSelector: true, ModelSelector: true, LanguageSwitch: true, }, }, }) expect(wrapper.text()).not.toContain('sidebar.search') expect(wrapper.text()).not.toContain('sidebar.reloadClientVersion') expect(wrapper.find('.sidebar-return-tab').exists()).toBe(true) }) it('uses short group labels and keeps group folding active when collapsed', async () => { mockAppStore.sidebarCollapsed = true const wrapper = mount(AppSidebar, { global: { stubs: { ProfileSelector: true, ModelSelector: true, LanguageSwitch: true, }, }, }) expect(wrapper.classes()).toContain('collapsed') expect(wrapper.findAll('.nav-group-label span').map(node => node.text())).toEqual([ 'sidebar.groupAgentShort', 'sidebar.groupMonitoringShort', 'sidebar.groupToolsShort', 'sidebar.groupSystemShort', ]) const agentGroup = wrapper.findAll('.nav-group')[0] expect(agentGroup.find('.nav-group-items').attributes('style')).toBeUndefined() await agentGroup.find('.nav-group-label').trigger('click') expect(agentGroup.find('.nav-group-items').attributes('style')).toContain('display: none') }) it('keeps MCP visible for admins while hiding device management', () => { localStorage.setItem('hermes_api_key', fakeJwt({ sub: '2', role: 'admin' })) const wrapper = mount(AppSidebar, { global: { stubs: { ProfileSelector: true, ModelSelector: true, LanguageSwitch: true, }, }, }) expect(wrapper.text()).toContain('sidebar.mcp') expect(wrapper.text()).not.toContain('sidebar.devices') }) it('uses the regular update button to open Docker upgrade guidance', async () => { mockAppStore.isDocker = true mockAppStore.updateAvailable = true mockAppStore.latestVersion = '0.6.29' const wrapper = mount(AppSidebar, { global: { stubs: { ProfileSelector: true, ModelSelector: true, LanguageSwitch: true, }, }, }) const button = wrapper.get('.update-btn') expect(button.classes()).not.toContain('docker-update-btn') expect(button.text()).toContain('sidebar.updateVersion') await button.trigger('click') expect(mockAppStore.doUpdate).not.toHaveBeenCalled() expect(wrapper.text()).toContain('sidebar.dockerUpdateGuide') }) it('keeps the original npm update action outside Docker', async () => { mockAppStore.isDocker = false mockAppStore.updateAvailable = true mockAppStore.latestVersion = '0.6.29' const wrapper = mount(AppSidebar, { global: { stubs: { ProfileSelector: true, ModelSelector: true, LanguageSwitch: true, }, }, }) await wrapper.get('.update-btn').trigger('click') expect(mockAppStore.doUpdate).toHaveBeenCalledOnce() expect(wrapper.text()).not.toContain('sidebar.dockerUpdateGuide') }) })