import { DOMWrapper, mount, flushPromises } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory, createRouter } from 'vue-router' import { defineComponent } from 'vue' import { createPinia } from 'pinia' import { useUnreadStore } from '@/stores/unread' import type { Profile } from '@shared/types' import WorkspaceShell from '@/components/app/WorkspaceShell.vue' const profiles: Profile[] = [ { name: 'default', agentName: '丫头', isDefault: true }, { name: 'ops:blue/team', agentName: '运维机器人', isDefault: false }, ] const SettingsCenterDialogStub = defineComponent({ name: 'SettingsCenterDialog', emits: ['close'], props: { open: Boolean, initialPage: String, }, template: '
', }) async function mountShell(start = '/chat') { const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/chat', component: { template: '
' } }, { path: '/history', component: { template: '
' } }, { path: '/conversations/automations', component: { template: '
' } }, { path: '/conversations', component: { template: '
' } }, { path: '/groups', component: { template: '
' } }, { path: '/kanban', component: { template: '
' } }, { path: '/files', component: { template: '
' } }, ], }) await router.push(start) await router.isReady() return mount(WorkspaceShell, { attachTo: document.body, props: { activeProfile: profiles[0], profiles, userName: 'owner', pairingUserName: 'owner', isAdmin: true, sidebarTitle: '历史记录', }, global: { plugins: [router, createPinia()], stubs: { SettingsCenterDialog: SettingsCenterDialogStub, BotPluginsDialog: defineComponent({emits:['close'],template:'
'}), BotSettingsDialog: defineComponent({props:['initialPage','isAdmin'],emits:['close'],template:'
'}), UpdateCheckDialog: defineComponent({emits:['close', 'manage'],template:'
'}), AboutDialog: defineComponent({emits:['close'],template:'
'}), AgentAvatar: true, AppIcon: true, BrandMark: true, YaoYaoSidebarIcon: true, }, }, }) } beforeEach(() => { localStorage.clear() useUnreadStore().reset() delete window.yaoyaoDesktop }) afterEach(() => { document.body.innerHTML = '' }) describe('Workspace shell account controls', () => { it('includes the Kanban workspace in desktop and mobile navigation', async () => { const wrapper = await mountShell() expect(wrapper.get('.desktop-sidebar').text()).toContain('看板') expect(wrapper.get('.desktop-sidebar').text()).toContain('历史记录') expect(wrapper.get('.mobile-drawer').text()).toContain('看板') await wrapper.get('.desktop-sidebar .sidebar-feature-nav button[title="看板"]').trigger('click') await vi.waitFor(() => expect(wrapper.get('.sidebar-context__heading').text()).toContain('看板列表')) expect(wrapper.get('.desktop-sidebar').find('button[aria-label="搜索"]').exists()).toBe(false) expect(wrapper.get('.mobile-drawer').find('button[aria-label="搜索"]').exists()).toBe(false) wrapper.unmount() }) it('keeps Agent switching focused and opens settings from its independent button', async () => { const wrapper = await mountShell() const desktop = wrapper.get('.desktop-sidebar') const agentTrigger = desktop.get('.sidebar-account-switcher__main') expect(agentTrigger.attributes('aria-haspopup')).toBe('listbox') expect(agentTrigger.attributes('aria-expanded')).toBe('false') await agentTrigger.trigger('click') expect(agentTrigger.attributes('aria-expanded')).toBe('true') const menu = desktop.get('.profile-menu') expect(menu.attributes('role')).toBe('listbox') expect(menu.text()).toContain('切换机器人') expect(menu.text()).toContain('丫头') expect(menu.text()).toContain('运维机器人') expect(menu.text()).not.toContain('机器人设置') expect(menu.findAll('button')).toHaveLength(profiles.length) expect(menu.findAll('[role="option"]').map(option => option.attributes('aria-selected'))).toEqual(['true', 'false']) expect(menu.text()).not.toContain('账号安全') expect(menu.text()).not.toContain('系统管理') expect(menu.text()).not.toContain('系统更新') expect(menu.text()).not.toContain('退出登录') expect(document.activeElement).toBe(menu.find('[role="option"][aria-selected="true"]').element) await menu.trigger('keydown', { key: 'ArrowDown' }) const targetProfile = menu.findAll('button') .find(button => button.text().includes('运维机器人'))! expect(document.activeElement).toBe(targetProfile.element) await targetProfile.trigger('click') expect(wrapper.emitted('selectProfile')).toEqual([['ops:blue/team']]) expect(desktop.find('.profile-menu').exists()).toBe(false) const settingsTrigger = desktop.get('.sidebar-settings-trigger') expect(settingsTrigger.text()).toBe('') expect(settingsTrigger.attributes('aria-label')).toBe('设置与模式') await settingsTrigger.trigger('click') const actions = document.querySelectorAll('.workspace-settings-menu [role="menuitem"]') expect([...actions].map(button => button.textContent?.trim())).toEqual(['我的设置', '关于', '检测更新', '帮助', '进入 Bot 模式']) actions[0]!.click() await wrapper.vm.$nextTick() const settings = wrapper.get('[data-testid="settings-center"]') expect(settings.attributes('data-page')).toBe('agent-identity') expect(desktop.find('.profile-menu').exists()).toBe(false) await wrapper.get('[data-testid="close-settings"]').trigger('click') await wrapper.vm.$nextTick() expect(document.activeElement).toBe(settingsTrigger.element) const mobileNavigation = wrapper.get('.mobile-header button[aria-label="打开导航"]') await mobileNavigation.trigger('click') await wrapper.vm.$nextTick() expect(wrapper.get('.mobile-header').attributes()).toHaveProperty('inert') expect(document.activeElement).toBe(wrapper.get('.mobile-drawer button[aria-label="关闭导航"]').element) await wrapper.get('.mobile-drawer .sidebar-settings-trigger').trigger('click') document.querySelector('.workspace-settings-menu [role="menuitem"]')!.click() await wrapper.vm.$nextTick() await wrapper.get('[data-testid="close-settings"]').trigger('click') await wrapper.vm.$nextTick() expect(document.activeElement).toBe(mobileNavigation.element) expect(desktop.findAll('.sidebar-feature-nav button').map(button => button.text())).not.toContain('聊天') await settingsTrigger.trigger('click') document.querySelectorAll('.workspace-settings-menu [role="menuitem"]')[4]!.click() await vi.waitFor(() => expect(wrapper.classes()).toContain('workspace-shell--conversations')) expect(document.querySelector('.workspace-settings-menu')).toBeNull() wrapper.unmount() }) }) it('uses the reference Bot list header and keeps mode changes in settings', async () => { const wrapper = await mountShell('/conversations') const rail = wrapper.get('.desktop-sidebar') expect(wrapper.find('.sidebar-settings-trigger').exists()).toBe(false) expect(rail.find('.sidebar-feature-nav').exists()).toBe(false) expect(rail.find('.sidebar-primary-action').exists()).toBe(false) expect(rail.find('.sidebar-footer').exists()).toBe(true) expect(rail.get('.sidebar-account-switcher__main').text()).toContain('owner') expect(rail.get('.account-initial-avatar').text()).toBe('O') expect(rail.find('.sidebar-account-switcher__main .agent-avatar').exists()).toBe(false) expect(rail.find('.bot-logo-trigger').exists()).toBe(true) expect(rail.find('.bot-account-trigger').exists()).toBe(false) expect(rail.find('.sidebar-collapse').exists()).toBe(false) expect(rail.find('.sidebar-context__heading').exists()).toBe(false) await rail.get('.sidebar-account-switcher__main').trigger('click') const accountItems = [...document.querySelectorAll('.workspace-settings-menu [role="menuitem"]')] expect(accountItems.map(item => item.textContent?.trim())).toEqual(['我的设置', '关于', '检测更新', '帮助', '进入聊天模式']) expect(document.querySelector('.workspace-settings-menu a')?.href).toBe('https://yaoyao.samien.cn/') accountItems[0]!.click(); await wrapper.vm.$nextTick() expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('account-profile') await wrapper.get('[data-testid="close-settings"]').trigger('click') await rail.get('.sidebar-create-trigger').trigger('click') const items = document.querySelectorAll('.workspace-create-menu [role="menuitem"]') expect([...items].map(item => item.textContent?.trim())).toEqual(['新建 Bot', '新建群聊']) items[0]?.click() await wrapper.vm.$nextTick() expect(wrapper.emitted('createAgent')).toHaveLength(1) await rail.get('.sidebar-create-trigger').trigger('click') document.querySelectorAll('.workspace-create-menu [role="menuitem"]')[1]?.click() await wrapper.vm.$nextTick() expect(wrapper.emitted('createGroup')).toHaveLength(1) expect(wrapper.emitted('createRemoteAgent')).toBeUndefined() await rail.get('.sidebar-search-trigger').trigger('click') document.dispatchEvent(new CustomEvent('hermes-yaoyao:sidebar-search-close')) await wrapper.vm.$nextTick() wrapper.unmount() }) it('shows Bot tools only in Bot mode and restores keyboard focus after closing', async () => { const normal = await mountShell('/chat') expect(normal.find('.sidebar-tools-trigger').exists()).toBe(false) normal.unmount() const wrapper = await mountShell('/conversations') const trigger = wrapper.get('.desktop-sidebar .sidebar-tools-trigger') await trigger.trigger('click') const menu = document.querySelector('.workspace-tools-menu')! const items = [...menu.querySelectorAll('[role="menuitem"]')] expect(items.map(b => b.textContent?.trim())).toEqual(['Bot 设置', '自动化', '已连接应用']) expect(document.activeElement).toBe(items[0]) menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) expect(document.activeElement).toBe(items[1]) items[2]!.click(); await wrapper.vm.$nextTick() await flushPromises() expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false) expect(wrapper.find('[data-testid="plugins-dialog"]').exists()).toBe(true) await wrapper.get('[data-testid="plugins-dialog"] button').trigger('click'); await wrapper.vm.$nextTick() expect(document.activeElement).toBe(trigger.element) wrapper.unmount() }) it('opens dedicated Bot settings from tools and routes the existing VM shortcut there', async () => { const wrapper = await mountShell('/conversations') const trigger = wrapper.get('.desktop-sidebar .sidebar-tools-trigger') await trigger.trigger('click') ;[...document.querySelectorAll('.workspace-tools-menu button')].find(b => b.textContent?.trim() === 'Bot 设置')!.click() await flushPromises() expect(wrapper.get('[data-testid="bot-settings-dialog"]').attributes('data-page')).toBe('projects') expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false) await wrapper.get('[data-testid="bot-settings-dialog"] button').trigger('click') expect(document.activeElement).toBe(trigger.element) window.dispatchEvent(new Event('yaoyao:local-vm-settings')) await flushPromises() expect(wrapper.get('[data-testid="bot-settings-dialog"]').attributes('data-page')).toBe('vm') expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false) wrapper.unmount() }) it.each(['/chat', '/history', '/conversations', '/kanban', '/files'])('opens an independent About dialog from the menu in %s', async path => { const wrapper = await mountShell(path) const trigger = wrapper.get(path === '/conversations' ? '.desktop-sidebar .sidebar-account-switcher__main' : '.desktop-sidebar .sidebar-settings-trigger') await trigger.trigger('click') const item = [...document.querySelectorAll('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '关于')! item.click(); await flushPromises() expect(wrapper.find('[data-testid="about-dialog"]').exists()).toBe(true) expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false) await wrapper.get('[data-testid="about-dialog"] button').trigger('click'); await flushPromises() expect(document.activeElement).toBe(trigger.element) wrapper.unmount() }) it('navigates to the independent automation route without opening settings', async () => { const wrapper = await mountShell('/conversations') await wrapper.get('.desktop-sidebar .sidebar-tools-trigger').trigger('click') const item = [...document.querySelectorAll('.workspace-tools-menu button')].find(b => b.textContent?.trim() === '自动化')! item.click(); await flushPromises() expect(wrapper.vm.$router.currentRoute.value.path).toBe('/conversations/automations') expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false) wrapper.unmount() }) it('checks Web updates from the Bot account menu and preserves focus through update settings', async () => { const wrapper = await mountShell('/conversations') const trigger = wrapper.get('.desktop-sidebar .sidebar-account-switcher__main') await trigger.trigger('click') ;[...document.querySelectorAll('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '检测更新')!.click() await flushPromises() expect(wrapper.find('[data-testid="update-dialog"]').exists()).toBe(true) await wrapper.get('[data-testid="update-dialog"] button:nth-child(2)').trigger('click') expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('system-update') await wrapper.get('[data-testid="close-settings"]').trigger('click') await flushPromises() expect(document.activeElement).toBe(trigger.element) wrapper.unmount() }) it('opens the native updater, prevents duplicate clicks and lets a failed opening retry', async () => { const openUpdates = vi.fn().mockRejectedValueOnce(new Error('窗口打开失败')).mockResolvedValueOnce(undefined) window.yaoyaoDesktop = { openUpdates, openComputer: vi.fn(), computerClosed: vi.fn(), onComputerClose: vi.fn() } const wrapper = await mountShell('/conversations') await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click') const item = [...document.querySelectorAll('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '检测更新')! item.click(); item.click() await flushPromises() expect(openUpdates).toHaveBeenCalledTimes(1) expect(document.querySelector('[role="alert"]')?.textContent).toContain('窗口打开失败') item.click(); await flushPromises() expect(openUpdates).toHaveBeenCalledTimes(2) expect(document.querySelector('.workspace-settings-menu')).toBeNull() expect(wrapper.find('[data-testid="update-dialog"]').exists()).toBe(false) wrapper.unmount() }) it('does not expose server update controls to a non-admin browser account', async () => { const wrapper = await mountShell('/conversations') await wrapper.setProps({ isAdmin: false }) await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click') expect(document.querySelector('.workspace-settings-menu')?.textContent).not.toContain('检测更新') wrapper.unmount() }) it('opens the unread center beside the sidebar and restores focus after Escape', async () => { const wrapper = await mountShell('/conversations') const trigger = wrapper.get('.desktop-sidebar .unread-entry') vi.spyOn(trigger.element, 'getBoundingClientRect').mockReturnValue({ top: 86 } as DOMRect) vi.spyOn(wrapper.get('.desktop-sidebar').element, 'getBoundingClientRect').mockReturnValue({ right: 360 } as DOMRect) await trigger.trigger('click') expect(trigger.attributes('aria-expanded')).toBe('true') expect(wrapper.attributes()).toHaveProperty('inert') const panel = document.querySelector('#unread-center')! expect(panel).not.toBeNull() expect(document.activeElement).toBe(panel) expect(panel.parentElement?.style.getPropertyValue('--unread-left')).toBe('372px') expect(panel.parentElement?.style.getPropertyValue('--unread-top')).toBe('86px') await new DOMWrapper(panel).trigger('keydown', { key: 'Escape' }) await flushPromises() expect(document.querySelector('#unread-center')).toBeNull() expect(wrapper.attributes()).not.toHaveProperty('inert') expect(document.activeElement).toBe(trigger.element) wrapper.unmount() })