Replace the Pure Ink light/dark/comic theme system with the CYBER single-theme dark palette from the reviewed prototype: OKLCH surfaces, one neon-cyan accent, and monospace-first typography. Arco components were reading colors from arco.css because no ConfigProvider was mounted, so the theme values in arco-theme.ts never took effect. Bridge CYBER into Arco through its --color-* variables in global.scss instead, and align arco-theme.ts to match. Brightness and style switches are gone: useTheme now pins dark, the comic font faces and locale keys are removed, and ThemeSwitch.vue is deleted along with its sidebar slot and display setting.
230 lines
6.5 KiB
TypeScript
230 lines
6.5 KiB
TypeScript
// @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<any>()
|
|
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: '<div />' },
|
|
}))
|
|
|
|
vi.mock('@/components/layout/ModelSelector.vue', () => ({
|
|
default: { name: 'ModelSelector', template: '<div />' },
|
|
}))
|
|
|
|
vi.mock('@/components/layout/LanguageSwitch.vue', () => ({
|
|
default: { name: 'LanguageSwitch', template: '<div />' },
|
|
}))
|
|
|
|
vi.mock('@/components/common/RouteLinkItem.vue', () => ({
|
|
default: {
|
|
name: 'RouteLinkItem',
|
|
props: ['to', 'active'],
|
|
template: '<a class="route-link-item" :class="{ active }" href="#"><slot /></a>',
|
|
},
|
|
}))
|
|
|
|
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: '<button class="a-button-stub" v-bind="$attrs"><slot /></button>',
|
|
},
|
|
'a-modal': {
|
|
props: ['visible'],
|
|
emits: ['update:visible'],
|
|
template: '<div v-if="visible" class="a-modal-stub"><slot /></div>',
|
|
},
|
|
'a-tag': {
|
|
template: '<span class="a-tag-stub"><slot /></span>',
|
|
},
|
|
}
|
|
|
|
function fakeJwt(payload: Record<string, unknown>) {
|
|
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')
|
|
})
|
|
})
|