botPluginsPanel.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import { mount, flushPromises } from '@vue/test-utils'
  2. import { beforeEach, afterEach, expect, it, vi } from 'vitest'
  3. import BotPluginsPanel from '@/components/workspace/BotPluginsPanel.vue'
  4. import { apiRequest } from '@/api/client'
  5. vi.mock('@/api/client', () => ({ apiRequest: vi.fn() }))
  6. let failInventory = false, failAgents = false, canManage = true
  7. let agentIds: string[] = []
  8. const plugin = { id: 'plugin-1', name: '现有插件', transport: 'http', url: 'https://mcp.example.test', envKeys: [], headerKeys: ['Authorization'], agentIds: [], enabled: false, revision: 2, toolCount: 1, testedAt: 1 }
  9. beforeEach(() => {
  10. failInventory = false; failAgents = false; canManage = true; agentIds = []
  11. vi.mocked(apiRequest).mockReset().mockImplementation(async (path, options) => {
  12. if (options?.method && options.method !== 'GET') return {} as never
  13. if (path.endsWith('/settings')) return { configured: true, revision: 1, canManageMcp: canManage } as never
  14. if (path.endsWith('/mcp')) return { plugins: [{ ...plugin, agentIds }], canManage } as never
  15. if (path.endsWith('/apps/catalog')) return { cards: [{ slug: 'gmail', name: 'Gmail', description: '邮件' }] } as never
  16. if (path.endsWith('/apps')) {
  17. if (failInventory) throw new Error('暂时无法读取连接状态')
  18. return { authoritative: true, connections: [{ slug: 'gmail', accounts: [{ id: 'account-1', alias: '工作账号', status: 'ACTIVE' }], agentIds: [], revision: 0 }] } as never
  19. }
  20. if (failAgents) throw new Error('暂时无法读取 Bot 列表')
  21. return { agents: [{ id: 'current-bot', name: '小夭', archived: false }] } as never
  22. })
  23. })
  24. afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks() })
  25. const render = () => mount(BotPluginsPanel, { attachTo: document.body, global: { stubs: { AppIcon: true } } })
  26. function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(b => b.text() === text)! }
  27. it('retains connected accounts on failed refresh instead of presenting them as disconnected', async () => {
  28. const wrapper = render(); await flushPromises()
  29. expect(wrapper.text()).toContain('工作账号')
  30. failInventory = true
  31. await wrapper.get('[aria-label="刷新应用连接"]').trigger('click'); await flushPromises()
  32. expect(wrapper.get('[role="alert"]').text()).toContain('暂时无法读取连接状态')
  33. expect(wrapper.text()).toContain('工作账号')
  34. expect(button(wrapper, '添加账号').exists()).toBe(true)
  35. wrapper.unmount()
  36. })
  37. it('shows the same supplier logo in the directory and connected list', async () => {
  38. const wrapper = render(); await flushPromises()
  39. expect(wrapper.get('.app-card .provider-logo img').attributes('src')).toBe('/provider-logos/gmail.svg')
  40. await button(wrapper, '已连接 1').trigger('click')
  41. expect(wrapper.get('.app-card .provider-logo img').attributes('src')).toBe('/provider-logos/gmail.svg')
  42. wrapper.unmount()
  43. })
  44. it('shows a recognized MCP supplier logo and a placeholder for unknown services', async () => {
  45. const request = vi.mocked(apiRequest).getMockImplementation()!
  46. vi.mocked(apiRequest).mockImplementation(async (path, options) => path.endsWith('/mcp')
  47. ? { plugins: [{ ...plugin, name: 'GitHub MCP', url: 'https://api.githubcopilot.com/mcp' }, { ...plugin, id: 'unknown' }], canManage } as never
  48. : request(path, options))
  49. const wrapper = render(); await flushPromises()
  50. await button(wrapper, 'MCP 服务').trigger('click')
  51. const logos = wrapper.findAll('.mcp-card .provider-logo')
  52. expect(logos[0].get('img').attributes('src')).toBe('/provider-logos/github.svg')
  53. expect(logos[1].find('img').exists()).toBe(false)
  54. expect(logos[1].text()).toBe('现')
  55. wrapper.unmount()
  56. })
  57. it('keeps secret placeholders and invalid form input without submitting a broken configuration', async () => {
  58. const wrapper = render(); await flushPromises()
  59. await button(wrapper, 'MCP 服务').trigger('click')
  60. await button(wrapper, '编辑').trigger('click')
  61. const headers = wrapper.get<HTMLTextAreaElement>('.plugin-editor textarea')
  62. expect(JSON.parse(headers.element.value)).toEqual({ Authorization: true })
  63. await headers.setValue('{bad json')
  64. await wrapper.get('form[aria-label="MCP 服务配置"]').trigger('submit'); await flushPromises()
  65. expect(wrapper.get('[role="alert"]').text()).toContain('有效 JSON')
  66. expect(headers.element.value).toBe('{bad json')
  67. expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
  68. expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([true])
  69. wrapper.unmount()
  70. })
  71. it('disables host MCP management for non-admin accounts', async () => {
  72. canManage = false
  73. const wrapper = render(); await flushPromises()
  74. await button(wrapper, 'MCP 服务').trigger('click')
  75. expect(button(wrapper, '添加服务').attributes('disabled')).toBeDefined()
  76. expect(button(wrapper, '测试连接').attributes('disabled')).toBeDefined()
  77. expect(button(wrapper, '授权 Bot').attributes('disabled')).toBeDefined()
  78. wrapper.unmount()
  79. })
  80. it('rebinds stale MCP grants without resaving credentials or resetting the connection test', async () => {
  81. agentIds = ['deleted-bot']
  82. const wrapper = render(); await flushPromises()
  83. await button(wrapper, 'MCP 服务').trigger('click')
  84. expect(wrapper.text()).toContain('服务器')
  85. expect(wrapper.text()).toContain('授权已失效')
  86. await button(wrapper, '授权 Bot').trigger('click')
  87. const form = wrapper.get('form[aria-label="MCP Bot 授权"]')
  88. await form.get('input[type="checkbox"]').setValue(true)
  89. await form.trigger('submit'); await flushPromises()
  90. expect(vi.mocked(apiRequest)).toHaveBeenCalledWith('/api/app/bot-tools/mcp/plugin-1', {
  91. method: 'PATCH', body: { agentIds: ['current-bot'], revision: 2 },
  92. })
  93. expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
  94. expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([false])
  95. wrapper.unmount()
  96. })
  97. it('drops missing Bot ids when editing an old service while keeping its HTTP URL', async () => {
  98. agentIds = ['deleted-bot', 'current-bot']
  99. const wrapper = render(); await flushPromises()
  100. await button(wrapper, 'MCP 服务').trigger('click')
  101. await button(wrapper, '编辑').trigger('click')
  102. await wrapper.get('form[aria-label="MCP 服务配置"]').trigger('submit'); await flushPromises()
  103. const call = vi.mocked(apiRequest).mock.calls.find(([, options]) => options?.method === 'PUT')!
  104. expect(call[1]?.body).toMatchObject({ definition: { url: plugin.url, transport: 'http', agentIds: ['current-bot'], headers: { Authorization: true } } })
  105. wrapper.unmount()
  106. })
  107. it('does not treat a failed Bot inventory as deleted grants', async () => {
  108. agentIds = ['current-bot']; failAgents = true
  109. const wrapper = render(); await flushPromises()
  110. await button(wrapper, 'MCP 服务').trigger('click')
  111. expect(wrapper.text()).not.toContain('授权已失效')
  112. expect(button(wrapper, '授权 Bot').attributes('disabled')).toBeDefined()
  113. expect(button(wrapper, '编辑').attributes('disabled')).toBeDefined()
  114. wrapper.unmount()
  115. })