workspaceShell.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import { mount, flushPromises } from '@vue/test-utils'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { createMemoryHistory, createRouter } from 'vue-router'
  4. import { defineComponent } from 'vue'
  5. import type { Profile } from '@shared/types'
  6. import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
  7. const profiles: Profile[] = [
  8. { name: 'default', agentName: '丫头', isDefault: true },
  9. { name: 'ops:blue/team', agentName: '运维机器人', isDefault: false },
  10. ]
  11. const SettingsCenterDialogStub = defineComponent({
  12. name: 'SettingsCenterDialog',
  13. emits: ['close'],
  14. props: {
  15. open: Boolean,
  16. initialPage: String,
  17. },
  18. template: '<div v-if="open" data-testid="settings-center" :data-page="initialPage"><button data-testid="close-settings" type="button" @click="$emit(\'close\')">close</button></div>',
  19. })
  20. async function mountShell(start = '/chat') {
  21. const router = createRouter({
  22. history: createMemoryHistory(),
  23. routes: [
  24. { path: '/chat', component: { template: '<div></div>' } },
  25. { path: '/history', component: { template: '<div></div>' } },
  26. { path: '/conversations/automations', component: { template: '<div></div>' } },
  27. { path: '/conversations', component: { template: '<div></div>' } },
  28. { path: '/groups', component: { template: '<div></div>' } },
  29. { path: '/kanban', component: { template: '<div></div>' } },
  30. { path: '/files', component: { template: '<div></div>' } },
  31. ],
  32. })
  33. await router.push(start)
  34. await router.isReady()
  35. return mount(WorkspaceShell, {
  36. attachTo: document.body,
  37. props: {
  38. activeProfile: profiles[0],
  39. profiles,
  40. userName: 'owner',
  41. pairingUserName: 'owner',
  42. isAdmin: true,
  43. sidebarTitle: '历史记录',
  44. },
  45. global: {
  46. plugins: [router],
  47. stubs: {
  48. SettingsCenterDialog: SettingsCenterDialogStub,
  49. BotPluginsDialog: defineComponent({emits:['close'],template:'<div data-testid="plugins-dialog"><button @click="$emit(\'close\')">关闭已连接应用</button></div>'}),
  50. UpdateCheckDialog: defineComponent({emits:['close', 'manage'],template:'<div data-testid="update-dialog"><button @click="$emit(\'close\')">关闭检测更新</button><button @click="$emit(\'manage\')">前往更新</button></div>'}),
  51. AboutDialog: defineComponent({emits:['close'],template:'<div data-testid="about-dialog"><button @click="$emit(\'close\')">关闭关于</button></div>'}),
  52. AgentAvatar: true,
  53. AppIcon: true,
  54. BrandMark: true,
  55. YaoYaoSidebarIcon: true,
  56. },
  57. },
  58. })
  59. }
  60. beforeEach(() => {
  61. localStorage.clear()
  62. delete window.yaoyaoDesktop
  63. })
  64. afterEach(() => {
  65. document.body.innerHTML = ''
  66. })
  67. describe('Workspace shell account controls', () => {
  68. it('includes the Kanban workspace in desktop and mobile navigation', async () => {
  69. const wrapper = await mountShell()
  70. expect(wrapper.get('.desktop-sidebar').text()).toContain('看板')
  71. expect(wrapper.get('.desktop-sidebar').text()).toContain('历史记录')
  72. expect(wrapper.get('.mobile-drawer').text()).toContain('看板')
  73. await wrapper.get('.desktop-sidebar .sidebar-feature-nav button[title="看板"]').trigger('click')
  74. await vi.waitFor(() => expect(wrapper.get('.sidebar-context__heading').text()).toContain('看板列表'))
  75. expect(wrapper.get('.desktop-sidebar').find('button[aria-label="搜索"]').exists()).toBe(false)
  76. expect(wrapper.get('.mobile-drawer').find('button[aria-label="搜索"]').exists()).toBe(false)
  77. wrapper.unmount()
  78. })
  79. it('keeps Agent switching focused and opens settings from its independent button', async () => {
  80. const wrapper = await mountShell()
  81. const desktop = wrapper.get('.desktop-sidebar')
  82. const agentTrigger = desktop.get('.sidebar-account-switcher__main')
  83. expect(agentTrigger.attributes('aria-haspopup')).toBe('listbox')
  84. expect(agentTrigger.attributes('aria-expanded')).toBe('false')
  85. await agentTrigger.trigger('click')
  86. expect(agentTrigger.attributes('aria-expanded')).toBe('true')
  87. const menu = desktop.get('.profile-menu')
  88. expect(menu.attributes('role')).toBe('listbox')
  89. expect(menu.text()).toContain('切换机器人')
  90. expect(menu.text()).toContain('丫头')
  91. expect(menu.text()).toContain('运维机器人')
  92. expect(menu.text()).not.toContain('机器人设置')
  93. expect(menu.findAll('button')).toHaveLength(profiles.length)
  94. expect(menu.findAll('[role="option"]').map(option => option.attributes('aria-selected'))).toEqual(['true', 'false'])
  95. expect(menu.text()).not.toContain('账号安全')
  96. expect(menu.text()).not.toContain('系统管理')
  97. expect(menu.text()).not.toContain('系统更新')
  98. expect(menu.text()).not.toContain('退出登录')
  99. expect(document.activeElement).toBe(menu.find('[role="option"][aria-selected="true"]').element)
  100. await menu.trigger('keydown', { key: 'ArrowDown' })
  101. const targetProfile = menu.findAll<HTMLButtonElement>('button')
  102. .find(button => button.text().includes('运维机器人'))!
  103. expect(document.activeElement).toBe(targetProfile.element)
  104. await targetProfile.trigger('click')
  105. expect(wrapper.emitted('selectProfile')).toEqual([['ops:blue/team']])
  106. expect(desktop.find('.profile-menu').exists()).toBe(false)
  107. const settingsTrigger = desktop.get<HTMLButtonElement>('.sidebar-settings-trigger')
  108. expect(settingsTrigger.text()).toBe('')
  109. expect(settingsTrigger.attributes('aria-label')).toBe('设置与模式')
  110. await settingsTrigger.trigger('click')
  111. const actions = document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')
  112. expect([...actions].map(button => button.textContent?.trim())).toEqual(['设置', '关于', '检测更新', '帮助', '进入 Bot 模式'])
  113. actions[0]!.click()
  114. await wrapper.vm.$nextTick()
  115. const settings = wrapper.get('[data-testid="settings-center"]')
  116. expect(settings.attributes('data-page')).toBe('agent-identity')
  117. expect(desktop.find('.profile-menu').exists()).toBe(false)
  118. await wrapper.get('[data-testid="close-settings"]').trigger('click')
  119. await wrapper.vm.$nextTick()
  120. expect(document.activeElement).toBe(settingsTrigger.element)
  121. const mobileNavigation = wrapper.get<HTMLButtonElement>('.mobile-header button[aria-label="打开导航"]')
  122. await mobileNavigation.trigger('click')
  123. await wrapper.vm.$nextTick()
  124. expect(wrapper.get('.mobile-header').attributes()).toHaveProperty('inert')
  125. expect(document.activeElement).toBe(wrapper.get('.mobile-drawer button[aria-label="关闭导航"]').element)
  126. await wrapper.get('.mobile-drawer .sidebar-settings-trigger').trigger('click')
  127. document.querySelector<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')!.click()
  128. await wrapper.vm.$nextTick()
  129. await wrapper.get('[data-testid="close-settings"]').trigger('click')
  130. await wrapper.vm.$nextTick()
  131. expect(document.activeElement).toBe(mobileNavigation.element)
  132. expect(desktop.findAll('.sidebar-feature-nav button').map(button => button.text())).not.toContain('聊天')
  133. await settingsTrigger.trigger('click')
  134. document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')[4]!.click()
  135. await vi.waitFor(() => expect(wrapper.classes()).toContain('workspace-shell--conversations'))
  136. expect(document.querySelector('.workspace-settings-menu')).toBeNull()
  137. wrapper.unmount()
  138. })
  139. })
  140. it('uses the reference Bot list header and keeps mode changes in settings', async () => {
  141. const wrapper = await mountShell('/conversations')
  142. const rail = wrapper.get('.desktop-sidebar')
  143. expect(wrapper.find('.sidebar-settings-trigger').exists()).toBe(false)
  144. expect(rail.find('.sidebar-feature-nav').exists()).toBe(false)
  145. expect(rail.find('.sidebar-primary-action').exists()).toBe(false)
  146. expect(rail.find('.sidebar-footer').exists()).toBe(true)
  147. expect(rail.get('.sidebar-account-switcher__main').text()).toContain('owner')
  148. expect(rail.get('.account-initial-avatar').text()).toBe('O')
  149. expect(rail.find('.sidebar-account-switcher__main .agent-avatar').exists()).toBe(false)
  150. expect(rail.find('.bot-logo-trigger').exists()).toBe(true)
  151. expect(rail.find('.bot-account-trigger').exists()).toBe(false)
  152. expect(rail.find('.sidebar-collapse').exists()).toBe(false)
  153. expect(rail.find('.sidebar-context__heading').exists()).toBe(false)
  154. await rail.get('.sidebar-account-switcher__main').trigger('click')
  155. const accountItems = [...document.querySelectorAll<HTMLElement>('.workspace-settings-menu [role="menuitem"]')]
  156. expect(accountItems.map(item => item.textContent?.trim())).toEqual(['设置', '关于', '检测更新', '帮助', '进入聊天模式'])
  157. expect(document.querySelector<HTMLAnchorElement>('.workspace-settings-menu a')?.href).toBe('https://yaoyao.samien.cn/')
  158. accountItems[0]!.click(); await wrapper.vm.$nextTick()
  159. expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('account-security')
  160. await wrapper.get('[data-testid="close-settings"]').trigger('click')
  161. await rail.get('.sidebar-create-trigger').trigger('click')
  162. const items = document.querySelectorAll<HTMLButtonElement>('.workspace-create-menu [role="menuitem"]')
  163. expect([...items].map(item => item.textContent?.trim())).toEqual(['新建 Bot', '添加远程机器人', '新建群聊'])
  164. items[0]?.click()
  165. await wrapper.vm.$nextTick()
  166. expect(wrapper.emitted('createAgent')).toHaveLength(1)
  167. await rail.get('.sidebar-create-trigger').trigger('click')
  168. document.querySelectorAll<HTMLButtonElement>('.workspace-create-menu [role="menuitem"]')[1]?.click()
  169. await wrapper.vm.$nextTick()
  170. expect(wrapper.emitted('createRemoteAgent')).toHaveLength(1)
  171. await rail.get('.sidebar-search-trigger').trigger('click')
  172. document.dispatchEvent(new CustomEvent('hermes-yaoyao:sidebar-search-close'))
  173. await wrapper.vm.$nextTick()
  174. wrapper.unmount()
  175. })
  176. it('shows Bot tools only in Bot mode and restores keyboard focus after closing', async () => {
  177. const normal = await mountShell('/chat')
  178. expect(normal.find('.sidebar-tools-trigger').exists()).toBe(false)
  179. normal.unmount()
  180. const wrapper = await mountShell('/conversations')
  181. const trigger = wrapper.get<HTMLButtonElement>('.desktop-sidebar .sidebar-tools-trigger')
  182. await trigger.trigger('click')
  183. const menu = document.querySelector<HTMLElement>('.workspace-tools-menu')!
  184. const items = [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
  185. expect(items.map(b => b.textContent?.trim())).toEqual(['自动化', '已连接应用'])
  186. expect(document.activeElement).toBe(items[0])
  187. menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
  188. expect(document.activeElement).toBe(items[1])
  189. items[1]!.click(); await wrapper.vm.$nextTick()
  190. await flushPromises()
  191. expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
  192. expect(wrapper.find('[data-testid="plugins-dialog"]').exists()).toBe(true)
  193. await wrapper.get('[data-testid="plugins-dialog"] button').trigger('click'); await wrapper.vm.$nextTick()
  194. expect(document.activeElement).toBe(trigger.element)
  195. wrapper.unmount()
  196. })
  197. it.each(['/chat', '/history', '/conversations', '/kanban', '/files'])('opens an independent About dialog from the menu in %s', async path => {
  198. const wrapper = await mountShell(path)
  199. const trigger = wrapper.get<HTMLButtonElement>(path === '/conversations' ? '.desktop-sidebar .sidebar-account-switcher__main' : '.desktop-sidebar .sidebar-settings-trigger')
  200. await trigger.trigger('click')
  201. const item = [...document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '关于')!
  202. item.click(); await flushPromises()
  203. expect(wrapper.find('[data-testid="about-dialog"]').exists()).toBe(true)
  204. expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
  205. await wrapper.get('[data-testid="about-dialog"] button').trigger('click'); await flushPromises()
  206. expect(document.activeElement).toBe(trigger.element)
  207. wrapper.unmount()
  208. })
  209. it('navigates to the independent automation route without opening settings', async () => {
  210. const wrapper = await mountShell('/conversations')
  211. await wrapper.get('.desktop-sidebar .sidebar-tools-trigger').trigger('click')
  212. const item = [...document.querySelectorAll<HTMLButtonElement>('.workspace-tools-menu button')].find(b => b.textContent?.trim() === '自动化')!
  213. item.click(); await flushPromises()
  214. expect(wrapper.vm.$router.currentRoute.value.path).toBe('/conversations/automations')
  215. expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
  216. wrapper.unmount()
  217. })
  218. it('checks Web updates from the Bot account menu and preserves focus through update settings', async () => {
  219. const wrapper = await mountShell('/conversations')
  220. const trigger = wrapper.get<HTMLButtonElement>('.desktop-sidebar .sidebar-account-switcher__main')
  221. await trigger.trigger('click')
  222. ;[...document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '检测更新')!.click()
  223. await flushPromises()
  224. expect(wrapper.find('[data-testid="update-dialog"]').exists()).toBe(true)
  225. await wrapper.get('[data-testid="update-dialog"] button:nth-child(2)').trigger('click')
  226. expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('system-update')
  227. await wrapper.get('[data-testid="close-settings"]').trigger('click')
  228. await flushPromises()
  229. expect(document.activeElement).toBe(trigger.element)
  230. wrapper.unmount()
  231. })
  232. it('opens the native updater, prevents duplicate clicks and lets a failed opening retry', async () => {
  233. const openUpdates = vi.fn().mockRejectedValueOnce(new Error('窗口打开失败')).mockResolvedValueOnce(undefined)
  234. window.yaoyaoDesktop = { openUpdates, openComputer: vi.fn(), computerClosed: vi.fn(), onComputerClose: vi.fn() }
  235. const wrapper = await mountShell('/conversations')
  236. await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
  237. const item = [...document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '检测更新')!
  238. item.click(); item.click()
  239. await flushPromises()
  240. expect(openUpdates).toHaveBeenCalledTimes(1)
  241. expect(document.querySelector('[role="alert"]')?.textContent).toContain('窗口打开失败')
  242. item.click(); await flushPromises()
  243. expect(openUpdates).toHaveBeenCalledTimes(2)
  244. expect(document.querySelector('.workspace-settings-menu')).toBeNull()
  245. expect(wrapper.find('[data-testid="update-dialog"]').exists()).toBe(false)
  246. wrapper.unmount()
  247. })
  248. it('does not expose server update controls to a non-admin browser account', async () => {
  249. const wrapper = await mountShell('/conversations')
  250. await wrapper.setProps({ isAdmin: false })
  251. await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
  252. expect(document.querySelector('.workspace-settings-menu')?.textContent).not.toContain('检测更新')
  253. wrapper.unmount()
  254. })