- Vendor ArkScope decorative layers (ScrambleTitle / IsometricWave) with provenance notes; decorative-only per AGENTS.md red lines - Migrate theme tokens to Arco, register the Arco theme, and add hook adapter composables (useMessage / useDialog / useNotification) with matching signatures - Sample pages: LoginView and AppSidebar on the new baseline; docs/arco-mapping.md and docs/ui-baseline.md land - agent-bridge: prefer the PM-selected venv from facts.json over legacy in-tree venvs (legacy lacks the mcp extra, so every MCP server showed 0 tools / not connected) - agent-bridge python (vendored, deliberate deviation): MCP discovery import fallback and pyyaml -> hermes_yaml so plugins and MCP work under the PM env; install the mcp extra via `hermes pm install --extra mcp` - Chat-chain fragment: docs/chat-chain-changes/2026-10-17-phase005-arkscope-baseline.md Verified: harness:check, test (2020 passed), test:e2e (30 passed), build all green; manual: plugins 100/0 warnings, MCP 6/6 servers connected with 104 tools.
239 lines
6.7 KiB
TypeScript
239 lines
6.7 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/layout/ThemeSwitch.vue', () => ({
|
|
default: { name: 'ThemeSwitch', 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,
|
|
ThemeSwitch: 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,
|
|
ThemeSwitch: 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,
|
|
ThemeSwitch: 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,
|
|
ThemeSwitch: 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,
|
|
ThemeSwitch: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
await wrapper.get('.update-btn').trigger('click')
|
|
|
|
expect(mockAppStore.doUpdate).toHaveBeenCalledOnce()
|
|
expect(wrapper.text()).not.toContain('sidebar.dockerUpdateGuide')
|
|
})
|
|
})
|