settingsCenterDialog.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { defineComponent } from 'vue'
  4. import type { Profile } from '@shared/types'
  5. import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
  6. const profiles: Profile[] = [
  7. { name: 'ops:blue/team', agentName: '运维机器人', isDefault: false },
  8. { name: 'default', agentName: '丫头', isDefault: true },
  9. ]
  10. function simpleStub(name: string, testId: string) {
  11. return defineComponent({ name, template: `<div data-testid="${testId}"></div>` })
  12. }
  13. const ModelServicesPanelStub = defineComponent({
  14. name: 'ModelServicesPanel',
  15. props: { profile: { type: String, required: true } },
  16. emits: ['dirty-change'],
  17. template: '<div data-testid="model-services" :data-profile="profile"><button data-testid="dirty-model" type="button" @click="$emit(\'dirty-change\', true)">dirty</button></div>',
  18. })
  19. const DuplexVoicePanelStub = defineComponent({
  20. name: 'DuplexVoicePanel',
  21. emits: ['dirty-change'],
  22. template: '<div data-testid="duplex-voice"><button data-testid="dirty-voice" type="button" @click="$emit(\'dirty-change\', true)">dirty</button></div>',
  23. })
  24. const SystemUpdatePanelStub = defineComponent({
  25. name: 'SystemUpdatePanel',
  26. emits: ['lock-change'],
  27. template: `
  28. <div data-testid="system-update">
  29. <button data-testid="lock-update" type="button" @click="$emit('lock-change', true)">lock</button>
  30. <button data-testid="unlock-update" type="button" @click="$emit('lock-change', false)">unlock</button>
  31. </div>
  32. `,
  33. })
  34. const childStubs = {
  35. Teleport: true,
  36. AgentAvatar: true,
  37. AppIcon: true,
  38. AgentIdentityPanel: simpleStub('AgentIdentityPanel', 'agent-identity'),
  39. ModelServicesPanel: ModelServicesPanelStub,
  40. AccountSecurityPanel: simpleStub('AccountSecurityPanel', 'account-security'),
  41. CredentialVaultPanel: simpleStub('CredentialVaultPanel', 'credential-vault'),
  42. NodePairingPanel: simpleStub('NodePairingPanel', 'node-pairing'),
  43. SystemOverviewPanel: simpleStub('SystemOverviewPanel', 'system-overview'),
  44. SystemManagementPanel: simpleStub('SystemManagementPanel', 'system-management'),
  45. DuplexVoicePanel: DuplexVoicePanelStub,
  46. SystemUpdatePanel: SystemUpdatePanelStub,
  47. }
  48. function mountSettings(overrides: Record<string, unknown> = {}) {
  49. return mount(SettingsCenterDialog, {
  50. attachTo: document.body,
  51. props: {
  52. open: true,
  53. activeProfile: profiles[0],
  54. profiles,
  55. userName: '管理员',
  56. pairingUserName: 'owner',
  57. isAdmin: true,
  58. ...overrides,
  59. } as never,
  60. global: { stubs: childStubs },
  61. })
  62. }
  63. function navigationButton(wrapper: VueWrapper, label: string) {
  64. const button = wrapper.findAll<HTMLButtonElement>('.settings-sidebar nav button')
  65. .find(candidate => candidate.text().includes(label))
  66. expect(button, `missing navigation button: ${label}`).toBeDefined()
  67. return button!
  68. }
  69. afterEach(() => {
  70. delete window.yaoyaoDesktop
  71. document.body.innerHTML = ''
  72. vi.restoreAllMocks()
  73. Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
  74. })
  75. describe('Settings center dialog', () => {
  76. it('describes vault management as belonging to the signed-in account', () => {
  77. const wrapper=mountSettings({botMode:true,initialPage:'credential-vault'})
  78. expect(wrapper.get('.settings-content__heading').text()).toContain('当前账号:owner')
  79. expect(wrapper.get('.settings-content__heading').text()).not.toContain('全局设置')
  80. expect(wrapper.find('[data-testid=credential-vault]').exists()).toBe(true)
  81. wrapper.unmount()
  82. })
  83. it('keeps desktop runtime controls out of a browser', () => {
  84. const wrapper = mountSettings({ initialPage: 'desktop-mode' })
  85. expect(wrapper.findAll('.settings-sidebar nav button').map(button => button.text())).not.toContain('运行模式')
  86. expect(wrapper.find('[aria-label="服务器与客户端模式"]').exists()).toBe(false)
  87. wrapper.unmount()
  88. })
  89. it('lets a signed-in member switch this desktop from client to server without logging out', async () => {
  90. let mode: 'client' | 'server' = 'client'
  91. const switchMode = vi.fn(async (next: 'client' | 'server') => { mode = next; return { ok: true } })
  92. Object.defineProperty(window, 'yaoyaoDesktop', { configurable: true, value: {
  93. modeState: vi.fn(async () => ({ mode, serverURL: 'http://fixture:15300', switching: false })),
  94. switchMode,
  95. openRemoteLogin: vi.fn(async () => {}),
  96. } })
  97. const wrapper = mountSettings({ isAdmin: false, botMode: true, initialPage: 'desktop-mode' })
  98. await flushPromises()
  99. expect(navigationButton(wrapper, '运行模式').attributes('aria-current')).toBe('page')
  100. expect(wrapper.text()).toContain('当前:客户端模式')
  101. await wrapper.get('input[value="server"]').setValue()
  102. expect(switchMode).not.toHaveBeenCalled()
  103. await wrapper.get('.mode-primary').trigger('click')
  104. await flushPromises()
  105. expect(switchMode).toHaveBeenCalledWith('server')
  106. expect(wrapper.text()).toContain('当前:服务器模式')
  107. expect(wrapper.emitted('logout')).toBeUndefined()
  108. wrapper.unmount()
  109. })
  110. it('preserves the current mode on a failed switch and can reopen server login', async () => {
  111. const openRemoteLogin = vi.fn(async () => {})
  112. Object.defineProperty(window, 'yaoyaoDesktop', { configurable: true, value: {
  113. modeState: vi.fn(async () => ({ mode: 'server', serverURL: 'http://127.0.0.1:15300', switching: false })),
  114. switchMode: vi.fn(async () => ({ ok: false, error: '连接失败,请重试。' })), openRemoteLogin,
  115. } })
  116. const wrapper = mountSettings({ initialPage: 'desktop-mode' })
  117. await flushPromises()
  118. await wrapper.get('input[value="client"]').setValue()
  119. await wrapper.get('.mode-primary').trigger('click'); await flushPromises()
  120. expect(wrapper.get('[role="alert"]').text()).toContain('连接失败')
  121. expect(wrapper.text()).toContain('当前:服务器模式')
  122. await wrapper.findAll('.mode-actions button').find(button => button.text() === '更换服务器…')!.trigger('click')
  123. await flushPromises()
  124. expect(openRemoteLogin).toHaveBeenCalledOnce()
  125. expect(wrapper.text()).not.toContain('弹出的窗口')
  126. expect(wrapper.find('[role="alert"]').exists()).toBe(false)
  127. wrapper.unmount()
  128. })
  129. it.each(['agent-identity', 'agent-models'])('keeps %s and the Profile selector out of Bot-mode personal settings', initialPage => {
  130. const wrapper = mountSettings({ botMode: true, initialPage })
  131. expect(wrapper.findAll('.settings-sidebar nav h3').map(heading => heading.text())).toEqual(['个人', '管理'])
  132. expect(wrapper.find('.settings-agent-selector').exists()).toBe(false)
  133. expect(wrapper.find('[data-testid="agent-identity"]').exists()).toBe(false)
  134. expect(wrapper.find('[data-testid="model-services"]').exists()).toBe(false)
  135. expect(wrapper.get('.settings-content__header').text()).toContain('账号资料')
  136. wrapper.unmount()
  137. })
  138. it('shows theme previews and filters settings without changing account scope', async () => {
  139. const wrapper = mountSettings({initialPage:'appearance',themePreference:'light'})
  140. expect(wrapper.get('[role="dialog"]').attributes('aria-label')).toBe('我的设置')
  141. expect(wrapper.findAll('.theme-options img')).toHaveLength(3)
  142. await wrapper.findAll('[role="radio"]').find(button=>button.text()==='深色')!.trigger('click')
  143. expect(wrapper.emitted('set-theme')).toEqual([['dark']])
  144. await wrapper.get('input[aria-label="搜索设置"]').setValue('Hermes')
  145. expect(wrapper.findAll('.settings-sidebar nav button').map(button=>button.text())).toEqual(['Hermes 连接'])
  146. wrapper.unmount()
  147. })
  148. it.each([false, true])('keeps independent tools and About outside settings (Bot mode=%s)', botMode => {
  149. const wrapper = mountSettings({ botMode, initialPage: 'bot-plugins' })
  150. const labels = wrapper.findAll('.settings-sidebar nav button').map(button => button.text())
  151. for (const label of ['插件', '已连接应用', '自动化', '关于', '本地虚拟机']) expect(labels).not.toContain(label)
  152. expect(wrapper.find('[data-testid="bot-plugins"]').exists()).toBe(false)
  153. expect(wrapper.find('.bot-about').exists()).toBe(false)
  154. wrapper.unmount()
  155. })
  156. it('groups administrator pages and routes Agent, voice, and system content to the right scope', async () => {
  157. const wrapper = mountSettings()
  158. expect(wrapper.findAll('.settings-sidebar nav h3').map(heading => heading.text())).toEqual([
  159. '个人',
  160. '管理',
  161. '基础机器人',
  162. ])
  163. await navigationButton(wrapper, '模型与 Provider').trigger('click')
  164. expect(navigationButton(wrapper, '模型与 Provider').attributes('aria-current')).toBe('page')
  165. const modelPanel = wrapper.get('[data-testid="model-services"]')
  166. expect(modelPanel.attributes('data-profile')).toBe('ops:blue/team')
  167. expect(wrapper.get('.settings-content__header').text()).toContain('正在设置:运维机器人 / ops:blue/team')
  168. await navigationButton(wrapper, '登录与安全').trigger('click')
  169. expect(wrapper.find('.settings-agent-selector').exists()).toBe(false)
  170. expect(wrapper.get('.settings-content__header').text()).toContain('当前账号:owner')
  171. const voiceButton = navigationButton(wrapper, '双流语音')
  172. expect(voiceButton.text()).toContain('全局')
  173. expect(voiceButton.element.closest('section')?.textContent).toContain('管理')
  174. await voiceButton.trigger('click')
  175. expect(wrapper.find('[data-testid="duplex-voice"]').exists()).toBe(true)
  176. expect(wrapper.get('.settings-content__header').text()).toContain('全局设置 · 仅管理员')
  177. })
  178. it('hides model management and the complete system group from non-admin users', () => {
  179. const wrapper = mountSettings({ isAdmin: false, initialPage: 'system-update' })
  180. const navigation = wrapper.get('.settings-sidebar nav')
  181. expect(navigation.findAll('h3').map(heading => heading.text())).toEqual(['个人'])
  182. expect(navigation.text()).not.toContain('模型与 Provider')
  183. expect(navigation.text()).not.toContain('管理')
  184. expect(navigation.text()).not.toContain('系统概览')
  185. expect(navigation.text()).not.toContain('双流语音')
  186. expect(navigation.text()).not.toContain('更新与回滚')
  187. expect(wrapper.find('[data-testid="system-update"]').exists()).toBe(false)
  188. expect(wrapper.get('.settings-content__header').text()).toContain('账号资料')
  189. })
  190. it('blocks closing and page changes while the update panel reports a lock', async () => {
  191. const wrapper = mountSettings({ initialPage: 'system-update' })
  192. expect(wrapper.find('[data-testid="system-update"]').exists()).toBe(true)
  193. await wrapper.get('[data-testid="lock-update"]').trigger('click')
  194. expect(wrapper.get<HTMLButtonElement>('.settings-center__close').element.disabled).toBe(true)
  195. await navigationButton(wrapper, '登录与安全').trigger('click')
  196. await wrapper.get('.settings-center-layer').trigger('mousedown')
  197. expect(wrapper.emitted('close')).toBeUndefined()
  198. expect(wrapper.get('.settings-content__header').text()).toContain('更新与回滚')
  199. expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(false)
  200. await wrapper.get('[data-testid="unlock-update"]').trigger('click')
  201. await navigationButton(wrapper, '登录与安全').trigger('click')
  202. expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(true)
  203. })
  204. it('asks before discarding an edited page and keeps the current page when declined', async () => {
  205. const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
  206. const wrapper = mountSettings()
  207. wrapper.findComponent({ name: 'AgentIdentityPanel' }).vm.$emit('dirty-change', true)
  208. await wrapper.vm.$nextTick()
  209. await navigationButton(wrapper, '登录与安全').trigger('click')
  210. expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
  211. expect(wrapper.get('.settings-content__header').text()).toContain('身份与头像')
  212. await navigationButton(wrapper, '登录与安全').trigger('click')
  213. expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(true)
  214. })
  215. it('protects Provider and global voice drafts from silent navigation or close', async () => {
  216. const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
  217. const modelWrapper = mountSettings({ initialPage: 'agent-models' })
  218. await modelWrapper.get('[data-testid="dirty-model"]').trigger('click')
  219. await navigationButton(modelWrapper, '登录与安全').trigger('click')
  220. expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
  221. expect(modelWrapper.find('[data-testid="model-services"]').exists()).toBe(true)
  222. modelWrapper.unmount()
  223. confirm.mockClear()
  224. const voiceWrapper = mountSettings({ initialPage: 'system-voice' })
  225. await voiceWrapper.get('[data-testid="dirty-voice"]').trigger('click')
  226. await voiceWrapper.get('.settings-center__close').trigger('click')
  227. expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
  228. expect(voiceWrapper.emitted('close')).toBeUndefined()
  229. })
  230. it('moves mobile focus into details and back to the active category', async () => {
  231. Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
  232. const wrapper = mountSettings()
  233. const identity = navigationButton(wrapper, '身份与头像')
  234. await identity.trigger('click')
  235. await wrapper.vm.$nextTick()
  236. expect(document.activeElement).toBe(wrapper.get('.settings-content__header h3').element)
  237. await wrapper.get('.mobile-back').trigger('click')
  238. await wrapper.vm.$nextTick()
  239. expect(document.activeElement).toBe(identity.element)
  240. const security = navigationButton(wrapper, '登录与安全')
  241. await security.trigger('click')
  242. await wrapper.vm.$nextTick()
  243. expect(document.activeElement).toBe(wrapper.get('.settings-content__header h3').element)
  244. await wrapper.get('.mobile-back').trigger('click')
  245. await wrapper.vm.$nextTick()
  246. expect(document.activeElement).toBe(security.element)
  247. })
  248. it('supports listbox keyboard navigation for the Agent selector', async () => {
  249. const wrapper = mountSettings()
  250. const trigger = wrapper.get('.settings-agent-selector > button')
  251. expect(trigger.attributes('aria-haspopup')).toBe('listbox')
  252. await trigger.trigger('click')
  253. await wrapper.vm.$nextTick()
  254. const menu = wrapper.get('.settings-agent-menu')
  255. const options = menu.findAll<HTMLButtonElement>('[role="option"]')
  256. expect(document.activeElement).toBe(options[0]!.element)
  257. await menu.trigger('keydown', { key: 'ArrowDown' })
  258. expect(document.activeElement).toBe(options[1]!.element)
  259. })
  260. })