agentManagementDialog.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import { flushPromises, mount } from '@vue/test-utils'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { nextTick } from 'vue'
  4. import AgentIdentityDialog from '@/components/app/AgentIdentityDialog.vue'
  5. import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
  6. import ModelServicesPanel from '@/components/app/ModelServicesPanel.vue'
  7. import { deleteModelService, listModelServices, saveDuplexVoiceSettings, saveLegacyModelService, saveModelService, saveProfileDefaultModel, validateModelService } from '@/api/agentManagement'
  8. vi.mock('@/api/agentManagement', () => ({
  9. listModelServices: vi.fn(async () => ({ endpoints: [], current: {} })),
  10. listLegacyModelServices: vi.fn(async () => [{ id: 'custom:tingly', name: 'tingly', base_url: 'http://tingly.test/v1', model: 'omni', models: ['omni'], discover_models: true, has_api_key: true, can_edit_api_key: true, is_current: true, source: 'legacy' }]),
  11. listModelCatalog: vi.fn(async () => [
  12. { slug: 'opencode-free', name: 'OpenCode Free', models: ['free-a'], isCurrent: false },
  13. { slug: 'custom:tingly', name: 'tingly', models: ['omni', 'omni-2'], isCurrent: true, currentModel: 'omni' },
  14. ]),
  15. saveLegacyModelService: vi.fn(async () => undefined),
  16. saveModelService: vi.fn(async () => ({ endpoints: [], current: {} })),
  17. saveProfileDefaultModel: vi.fn(async (_profile, provider, model) => ({ ok: true, provider, model })),
  18. validateModelService: vi.fn(async () => ({ ok: true, reachable: true, message: '', models: ['model-a', 'model-b'] })),
  19. activateModelService: vi.fn(async () => undefined),
  20. deleteModelService: vi.fn(async () => undefined),
  21. getDuplexVoiceSettings: vi.fn(async () => ({ hasApiKey: true, voices: [{ id: 'voice-a', name: '音色 A' }], currentVoiceId: 'voice-a', updatedAt: 1 })),
  22. saveDuplexVoiceSettings: vi.fn(async input => ({ hasApiKey: true, ...input, updatedAt: 2 })),
  23. }))
  24. const profile = { name: 'default', displayName: '丫头', isDefault: true }
  25. afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks(); vi.clearAllMocks() })
  26. describe('Agent management dialog', () => {
  27. it('keeps non-admin users on identity management only', async () => {
  28. const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: false } })
  29. await nextTick()
  30. expect(document.querySelector('[aria-label="机器人管理"]')?.textContent).toContain('自定义角色')
  31. expect(document.querySelector('.management-tabs')).toBeNull()
  32. expect(document.body.textContent).not.toContain('模型服务')
  33. expect(document.body.textContent).toContain('custom:tingly / omni')
  34. wrapper.unmount()
  35. })
  36. it('sets any catalog model as the Agent default without switching a session', async () => {
  37. const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: true } })
  38. await nextTick()
  39. document.querySelectorAll<HTMLButtonElement>('.management-tabs button')[1]!.click(); await flushPromises()
  40. const picker = document.querySelector<HTMLSelectElement>('select[aria-label="默认全局模型"]')!
  41. picker.value = JSON.stringify(['opencode-free', 'free-a'])
  42. picker.dispatchEvent(new Event('change'))
  43. await nextTick()
  44. document.querySelector<HTMLButtonElement>('.default-model-card button')!.click()
  45. await flushPromises()
  46. expect(saveProfileDefaultModel).toHaveBeenCalledWith('default', 'opencode-free', 'free-a')
  47. expect(document.body.textContent).toContain('仅影响新会话')
  48. wrapper.unmount()
  49. })
  50. it('shows admin model and global duplex voice panels', async () => {
  51. const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: true } })
  52. await nextTick()
  53. const tabs = [...document.querySelectorAll<HTMLButtonElement>('.management-tabs button')]
  54. expect(tabs.map(tab => tab.textContent)).toEqual(['身份', '模型服务', '双流语音'])
  55. tabs[1]!.click(); await flushPromises()
  56. expect(document.body.textContent).toContain('9119 Provider 与模型')
  57. expect(document.body.textContent).toContain('OpenCode Free')
  58. expect(document.body.textContent).toContain('custom:tingly')
  59. expect(document.body.textContent).toContain('只读')
  60. document.querySelectorAll<HTMLButtonElement>('.management-tabs button')[2]!.click(); await nextTick(); await nextTick()
  61. expect(document.body.textContent).toContain('整个 yaoyao 安装共享的全局设置')
  62. expect(document.querySelector<HTMLInputElement>('input[type="password"]')?.placeholder).toContain('留空保持不变')
  63. wrapper.unmount()
  64. })
  65. it('discovers and saves a model service without overwriting an unchanged secret', async () => {
  66. const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: true } })
  67. await nextTick()
  68. document.querySelectorAll<HTMLButtonElement>('.management-tabs button')[1]!.click(); await flushPromises()
  69. ;[...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.includes('新增服务'))!.click()
  70. await nextTick()
  71. const inputs = [...document.querySelectorAll<HTMLInputElement>('.service-editor input:not([type="checkbox"])')]
  72. for (const [input, value] of [[inputs[0], 'Local'], [inputs[1], 'http://127.0.0.1:9000/v1'], [inputs[2], 'model-a']] as const) {
  73. input!.value = value; input!.dispatchEvent(new Event('input'))
  74. }
  75. ;[...document.querySelectorAll<HTMLButtonElement>('.service-editor button')].find(button => button.textContent?.includes('测试连接'))!.click()
  76. await nextTick(); await nextTick()
  77. expect(validateModelService).toHaveBeenCalledOnce()
  78. expect(document.querySelector<HTMLTextAreaElement>('.service-editor textarea')?.value).toContain('model-b')
  79. ;[...document.querySelectorAll<HTMLButtonElement>('.service-editor button')].find(button => button.textContent?.includes('保存模型服务'))!.click()
  80. await nextTick(); await nextTick()
  81. expect(saveModelService).toHaveBeenCalledOnce()
  82. expect(vi.mocked(saveModelService).mock.calls[0]?.[1]).not.toHaveProperty('api_key')
  83. wrapper.unmount()
  84. })
  85. it('edits an existing legacy custom provider and replaces its visible model list', async () => {
  86. const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: true } })
  87. await nextTick()
  88. document.querySelectorAll<HTMLButtonElement>('.management-tabs button')[1]!.click(); await flushPromises()
  89. const legacyCard = [...document.querySelectorAll<HTMLElement>('.service-card')].find(card => card.textContent?.includes('custom:tingly'))!
  90. legacyCard.querySelector<HTMLButtonElement>('button')!.click(); await nextTick()
  91. const modelInput = document.querySelectorAll<HTMLInputElement>('.service-editor input:not([type="checkbox"])')[2]!
  92. modelInput.value = 'omni-2'; modelInput.dispatchEvent(new Event('input'))
  93. const models = document.querySelector<HTMLTextAreaElement>('.service-editor textarea')!
  94. models.value = 'omni\nomni-2'; models.dispatchEvent(new Event('input'))
  95. document.querySelector<HTMLFormElement>('.service-editor')!.dispatchEvent(new Event('submit'))
  96. await nextTick(); await nextTick()
  97. expect(saveLegacyModelService).toHaveBeenCalledWith('default', 'custom:tingly', expect.objectContaining({ model: 'omni-2', models: ['omni', 'omni-2'] }))
  98. wrapper.unmount()
  99. })
  100. it('confirms model deletion and rejects duplicate duplex voice ids', async () => {
  101. vi.mocked(listModelServices).mockResolvedValueOnce({ endpoints: [{ id: 'local', name: 'Local', base_url: 'http://127.0.0.1:9000/v1', model: 'model-a', models: ['model-a'], discover_models: true, has_api_key: true, is_current: false }], current: { provider: '', model: '', base_url: '' } })
  102. vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
  103. const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: true } })
  104. await nextTick()
  105. document.querySelectorAll<HTMLButtonElement>('.management-tabs button')[1]!.click(); await flushPromises()
  106. const deleteButton = [...document.querySelectorAll<HTMLButtonElement>('.service-card button')].find(button => button.textContent === '删除')!
  107. deleteButton.click(); await nextTick()
  108. expect(deleteModelService).not.toHaveBeenCalled()
  109. deleteButton.click(); await nextTick(); await nextTick()
  110. expect(deleteModelService).toHaveBeenCalledWith('default', 'local')
  111. vi.mocked(saveDuplexVoiceSettings).mockClear()
  112. document.querySelectorAll<HTMLButtonElement>('.management-tabs button')[2]!.click(); await nextTick(); await nextTick()
  113. ;[...document.querySelectorAll<HTMLButtonElement>('.voice-list-heading button')][0]!.click(); await nextTick()
  114. const idInputs = [...document.querySelectorAll<HTMLInputElement>('.voice-row label:first-child input')]
  115. idInputs[1]!.value = idInputs[0]!.value; idInputs[1]!.dispatchEvent(new Event('input'))
  116. document.querySelector<HTMLFormElement>('.voice-panel form')!.dispatchEvent(new Event('submit'))
  117. await nextTick()
  118. expect(document.querySelector('[role="alert"]')?.textContent).toContain('音色 ID 重复')
  119. expect(saveDuplexVoiceSettings).not.toHaveBeenCalled()
  120. wrapper.unmount()
  121. })
  122. it('preserves an identity draft across same-profile refreshes and resets only after save acknowledgement', async () => {
  123. const wrapper = mount(AgentIdentityPanel, { props: { profile, resetVersion: 0 } })
  124. const name = wrapper.get<HTMLInputElement>('input[autocomplete="off"]')
  125. await name.setValue('本地未保存名称')
  126. await wrapper.setProps({ profile: { ...profile, agentName: '远端刷新名称' } })
  127. expect(name.element.value).toBe('本地未保存名称')
  128. await wrapper.setProps({ resetVersion: 1 })
  129. expect(name.element.value).toBe('远端刷新名称')
  130. })
  131. it('does not mark an untouched editor dirty or clear a saved key after typing then erasing', async () => {
  132. const wrapper = mount(ModelServicesPanel, { props: { profile: 'default' } })
  133. await flushPromises()
  134. const legacyCard = wrapper.findAll<HTMLElement>('.service-card').find(card => card.text().includes('custom:tingly'))!
  135. await legacyCard.get('button').trigger('click')
  136. expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([false])
  137. const key = wrapper.get<HTMLInputElement>('.service-editor input[type="password"]')
  138. await key.setValue('temporary-key')
  139. await key.setValue('')
  140. expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([false])
  141. await wrapper.get<HTMLFormElement>('.service-editor').trigger('submit')
  142. await flushPromises()
  143. expect(saveLegacyModelService).toHaveBeenCalledOnce()
  144. expect(vi.mocked(saveLegacyModelService).mock.calls[0]?.[2]).not.toHaveProperty('api_key')
  145. })
  146. })