conversationSettings.test.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
  1. import { afterEach, expect, it, vi } from 'vitest'
  2. import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
  3. import { shallowRef } from 'vue'
  4. import { createMemoryHistory, createRouter, matchedRouteKey } from 'vue-router'
  5. import ConversationsView from '@/views/ConversationsView.vue'
  6. import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
  7. import { apiRequest } from '@/api/client'
  8. import type { WorkspaceConversation } from '@shared/workspace'
  9. vi.mock('@/api/client', () => ({ apiRequest: vi.fn(), setApiCsrfToken: vi.fn(), ApiError: class extends Error {} }))
  10. vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner' }, profiles: [], refreshProfileAvatars: async () => {} }) }))
  11. vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({}) }))
  12. const originalShowModal = HTMLDialogElement.prototype.showModal
  13. const originalClose = HTMLDialogElement.prototype.close
  14. let wrapper: VueWrapper | undefined
  15. afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.mocked(apiRequest).mockReset(); HTMLDialogElement.prototype.showModal = originalShowModal; HTMLDialogElement.prototype.close = originalClose })
  16. it.each(['direct', 'group'] as const)('opens and saves the menu target %s while another chat is selected', async kind => {
  17. const base: WorkspaceConversation = { id: 'current', kind: 'direct', name: '当前聊天', avatar: '', memberIds: ['current-bot'], instructions: '', administratorId: 'current-bot', mode: 'host', autoReplyIds: [], maxReplyRounds: 3, archived: false, pinned: false, readSeq: 0, lastSeq: 0, preview: '', createdAt: 1, updatedAt: 1 }
  18. const target: WorkspaceConversation = { ...base, id: 'target', kind, name: '目标聊天', memberIds: ['target-bot', ...(kind === 'group' ? ['current-bot'] : [])], administratorId: 'target-bot', instructions: '目标规则', activeAgentStates: kind === 'group' ? { 'target-bot': 'running' } : {} }
  19. const conversations = [base, target]
  20. const agents = ['current-bot', 'target-bot'].map(id => ({ id, name: id === 'target-bot' ? '目标机器人' : '当前机器人', avatar: id === 'current-bot' ? 'avatar-current' : '', instructions: `${id}规则`, nodeId: 'local', profile: 'default', archived: false }))
  21. vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
  22. vi.mocked(apiRequest).mockImplementation(async path => {
  23. if (path === '/api/app/capabilities') return { features: [], csrfToken: 'fixture-csrf' } as never
  24. if (path.split('?')[0] === '/api/app/workspace/snapshot') return { agents, conversations, cursor: 1, details: [] } as never
  25. if (path === '/api/app/agents') return { agents } as never
  26. if (path === '/api/app/agents/sources') return { sources: [{ nodeId: 'local', profile: 'default', name: '基础机器人' }] } as never
  27. if (path === '/api/app/conversations') return { conversations, cursor: 1 } as never
  28. if (path.startsWith('/api/app/events')) return { events: [], cursor: 1 } as never
  29. const conversation = conversations.find(c => path.split('?')[0] === `/api/app/conversations/${c.id}`)
  30. if (conversation) return { conversation, messages: [], runs: [], run: null, interactions: [], tasks: [] } as never
  31. return {} as never
  32. })
  33. HTMLDialogElement.prototype.showModal = function () { this.open = true }
  34. HTMLDialogElement.prototype.close = function () { this.open = false }
  35. const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/:id?', component: { template: '<div />' } }] })
  36. await router.push('/conversations/current'); await router.isReady()
  37. wrapper = shallowMount(ConversationsView, { attachTo: document.body, global: {
  38. provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) }, plugins: [router],
  39. stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [], attachFiles: async () => {} } }, Teleport: false },
  40. } })
  41. await flushPromises()
  42. expect(wrapper.findComponent(WorkspaceMessageTimeline).props()).toMatchObject({
  43. title: '当前机器人',
  44. headerAvatarName: '当前机器人',
  45. headerAvatar: 'avatar-current',
  46. headerAvatarKind: 'agent',
  47. })
  48. wrapper.findComponent({ name: 'ConversationList' }).vm.$emit('settings', 'target')
  49. await flushPromises()
  50. expect(router.currentRoute.value.path).toBe('/conversations/current')
  51. if (kind === 'direct') {
  52. expect(document.querySelector('dialog.editor')).toBeNull()
  53. const editor = wrapper.findComponent({ name: 'BotProfileDialog' })
  54. expect(editor.props('agent')).toMatchObject({ id: 'target-bot', name: '目标机器人' })
  55. expect(editor.props('conversationId')).toBe('target')
  56. expect(editor.props('draft')).toMatchObject({ name: '目标机器人', source: '["local","default"]' })
  57. editor.vm.$emit('save', { ...editor.props('draft'), name: '目标机器人' })
  58. } else {
  59. const dialog = document.querySelector<HTMLDialogElement>('dialog.editor')!
  60. expect(dialog.open).toBe(true)
  61. expect(dialog.querySelector('h2')?.textContent?.trim()).toBe('群聊设置')
  62. expect(dialog.querySelector<HTMLInputElement>('input[maxlength="100"]')?.value).toBe('目标聊天')
  63. expect(dialog.querySelector<HTMLInputElement>('input[value="target-bot"]')?.disabled).toBe(true)
  64. const stop = [...dialog.querySelectorAll('button')].find(button => button.textContent === '停止 目标机器人')!
  65. stop.click(); await flushPromises()
  66. expect(apiRequest).toHaveBeenCalledWith('/api/app/conversations/target/agents/target-bot/stop', expect.objectContaining({ method: 'POST' }))
  67. dialog.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
  68. }
  69. await flushPromises()
  70. expect(apiRequest).toHaveBeenCalledWith(kind === 'direct' ? '/api/app/agents/target-bot' : '/api/app/conversations/target', expect.objectContaining({ method: 'PATCH', body: expect.objectContaining({ name: kind === 'direct' ? '目标机器人' : '目标聊天' }) }))
  71. if (kind === 'direct') expect(router.currentRoute.value.path).toBe('/conversations/current')
  72. })