conversationRecovery.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  2. import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
  3. import { defineComponent, 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 { useUnreadStore } from '@/stores/unread'
  9. vi.mock('@/api/client', async importOriginal => ({ ...await importOriginal<typeof import('@/api/client')>(), apiRequest: vi.fn(), setApiCsrfToken: vi.fn() }))
  10. vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner' }, profiles: [], refreshProfileAvatars: async () => {} }) }))
  11. vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({}) }))
  12. let wrapper: VueWrapper | undefined
  13. const sources: Array<{ url: string; close: ReturnType<typeof vi.fn> }> = []
  14. const scrollToMessage = vi.fn(() => true)
  15. beforeEach(() => {
  16. vi.useFakeTimers()
  17. useUnreadStore().reset(); scrollToMessage.mockReset().mockReturnValue(true)
  18. sources.length = 0
  19. vi.stubGlobal('EventSource', class extends EventTarget {
  20. readonly close = vi.fn()
  21. constructor(readonly url: string) { super(); sources.push(this) }
  22. })
  23. })
  24. afterEach(() => {
  25. wrapper?.unmount(); wrapper = undefined
  26. useUnreadStore().reset()
  27. vi.mocked(apiRequest).mockReset(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers()
  28. })
  29. async function mountView(path = '/conversations', stubTimeline = false) {
  30. const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/:id?', component: { template: '<div />' } }] })
  31. await router.push(path); await router.isReady()
  32. wrapper = shallowMount(ConversationsView, { global: {
  33. provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) }, plugins: [router],
  34. stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [] } }, Teleport: true,
  35. ...(stubTimeline ? { MessageTimeline: defineComponent({
  36. name: 'WorkspaceMessageTimeline', props: ['messages', 'title'], emits: ['visibleMessages'], template: '<div />',
  37. methods: { scrollToMessage, scrollToBottom() {}, isFollowingBottom: () => true },
  38. }) } : {}),
  39. },
  40. } })
  41. await flushPromises()
  42. }
  43. function successfulResponse(path: string) {
  44. if (path === '/api/app/capabilities') return { features: [] }
  45. if (path === '/api/app/workspace/snapshot') return { agents: [], conversations: [], details: [], cursor: 17 }
  46. throw new Error(`Unexpected request ${path}`)
  47. }
  48. it.each([undefined, null, { agents: [], conversations: [], details: [] }])('recovers incomplete Bot initialization data without requiring a virtual machine', async snapshot => {
  49. let snapshots = 0
  50. vi.mocked(apiRequest).mockImplementation(async path => {
  51. if (path === '/api/app/workspace/snapshot' && snapshots++ === 0) return snapshot as never
  52. return successfulResponse(path) as never
  53. })
  54. await mountView()
  55. expect(sources).toHaveLength(0)
  56. expect(wrapper!.get('[role="alert"]').text()).toContain('Bot 会话数据尚未就绪')
  57. expect(wrapper!.text()).not.toContain('TypeError')
  58. await vi.advanceTimersByTimeAsync(1500); await flushPromises()
  59. expect(sources.map(source => source.url)).toEqual(['/api/app/events/stream?after=17'])
  60. expect(vi.mocked(apiRequest).mock.calls.some(([path]) => /local-vm|computer/.test(path))).toBe(false)
  61. })
  62. it('recovers an initial server outage while the browser stays online', async () => {
  63. vi.mocked(apiRequest).mockRejectedValueOnce(new Error('服务暂不可用')).mockImplementation(async path => successfulResponse(path) as never)
  64. await mountView()
  65. expect(sources).toHaveLength(0)
  66. await vi.advanceTimersByTimeAsync(1500); await flushPromises()
  67. expect(sources.map(source => source.url)).toEqual(['/api/app/events/stream?after=17'])
  68. expect(vi.mocked(apiRequest).mock.calls.filter(([path]) => path === '/api/app/capabilities')).toHaveLength(2)
  69. })
  70. it('cancels a scheduled initial recovery when the view is destroyed', async () => {
  71. vi.mocked(apiRequest).mockRejectedValue(new Error('服务暂不可用'))
  72. await mountView()
  73. wrapper!.unmount(); wrapper = undefined
  74. await vi.advanceTimersByTimeAsync(10_000)
  75. expect(apiRequest).toHaveBeenCalledTimes(1)
  76. expect(sources).toHaveLength(0)
  77. })
  78. it('aborts an in-flight snapshot and prevents a late response from connecting after destruction', async () => {
  79. let signal: AbortSignal | undefined, release!: (value: unknown) => void
  80. vi.mocked(apiRequest).mockImplementation(async (path, options) => {
  81. if (path === '/api/app/capabilities') return successfulResponse(path) as never
  82. signal = options?.signal as AbortSignal
  83. return new Promise(resolve => { release = resolve })
  84. })
  85. await mountView()
  86. expect(signal?.aborted).toBe(false)
  87. wrapper!.unmount(); wrapper = undefined
  88. expect(signal?.aborted).toBe(true)
  89. release(successfulResponse('/api/app/workspace/snapshot'))
  90. await flushPromises(); await vi.advanceTimersByTimeAsync(10_000)
  91. expect(sources).toHaveLength(0)
  92. expect(apiRequest).toHaveBeenCalledTimes(2)
  93. })
  94. it('cancels recovery while offline and restarts it only when connectivity returns', async () => {
  95. let online = true
  96. vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => online)
  97. vi.mocked(apiRequest).mockRejectedValueOnce(new Error('服务暂不可用')).mockImplementation(async path => successfulResponse(path) as never)
  98. await mountView()
  99. online = false; window.dispatchEvent(new Event('offline'))
  100. await vi.advanceTimersByTimeAsync(10_000)
  101. expect(apiRequest).toHaveBeenCalledTimes(1)
  102. online = true; window.dispatchEvent(new Event('online')); await flushPromises()
  103. expect(sources.map(source => source.url)).toEqual(['/api/app/events/stream?after=17'])
  104. })
  105. it('aborts a pending detail on going offline and ignores its late response', async () => {
  106. let online = true, signal: AbortSignal | undefined, release!: (value: unknown) => void
  107. vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => online)
  108. vi.mocked(apiRequest).mockImplementation(async (path, options) => {
  109. if (!path.startsWith('/api/app/conversations/')) return successfulResponse(path) as never
  110. signal = options?.signal as AbortSignal
  111. return new Promise(resolve => { release = resolve })
  112. })
  113. await mountView('/conversations/c')
  114. expect(signal?.aborted).toBe(false)
  115. online = false; window.dispatchEvent(new Event('offline'))
  116. expect(signal?.aborted).toBe(true)
  117. release({ conversation: { id: 'c', kind: 'direct', name: '迟到会话', memberIds: [], readSeq: 0, lastSeq: 0 }, messages: [], run: null, interactions: [], context: null })
  118. await flushPromises(); await vi.advanceTimersByTimeAsync(5000)
  119. expect(wrapper!.findComponent(WorkspaceMessageTimeline).props('title')).not.toBe('迟到会话')
  120. expect(sources).toHaveLength(0)
  121. })
  122. it.each([true, false])('resumes visible-message reads when entering the already-open conversation (target found: %s)', async found => {
  123. const unread = useUnreadStore()
  124. const pending = {
  125. total: 2, bot: 2, chat: 0, conversations: [{ mode: 'bot' as const, id: 'c', name: '研发助手', preview: '回复', count: 2, updatedAt: 1,
  126. messages: [{ id: 'first', seq: 1, version: 1 }, { id: 'second', seq: 2, version: 1 }],
  127. }],
  128. }
  129. vi.mocked(apiRequest).mockImplementation(async path => {
  130. if (path === '/api/app/unread') return pending as never
  131. if (path === '/api/app/unread/read') return { ...pending, total: 1, bot: 1,
  132. conversations: [{ ...pending.conversations[0], count: 1, messages: [pending.conversations[0]!.messages[1]] }],
  133. } as never
  134. if (path.startsWith('/api/app/conversations/c')) return {
  135. conversation: { id: 'c', kind: 'direct', name: '研发助手', memberIds: [], readSeq: 0, lastSeq: 2 },
  136. messages: ['first', 'second'].map(id => ({ id, role: 'assistant', status: 'complete', content: '回复', attachments: [], tools: [], createdAt: 1 })),
  137. hasOlder: false, run: null, interactions: [], context: null,
  138. } as never
  139. return successfulResponse(path) as never
  140. })
  141. unread.start('owner'); await flushPromises()
  142. await mountView('/conversations/c', true)
  143. expect(wrapper!.findComponent(WorkspaceMessageTimeline).props('title')).toBe('研发助手')
  144. scrollToMessage.mockReturnValue(found)
  145. await wrapper!.vm.$router.push({ query: { unread: found ? 'first' : 'removed-message' } }); await flushPromises()
  146. expect(scrollToMessage).toHaveBeenCalledWith(found ? 'first' : 'removed-message')
  147. expect(wrapper!.vm.$router.currentRoute.value.query.unread).toBeUndefined()
  148. wrapper!.findComponent(WorkspaceMessageTimeline).vm.$emit('visibleMessages', ['first'])
  149. await flushPromises()
  150. expect(unread.total).toBe(1)
  151. expect(unread.snapshot.conversations[0]?.messages.map(message => message.id)).toEqual(['second'])
  152. expect(apiRequest).toHaveBeenCalledWith('/api/app/unread/read', expect.objectContaining({
  153. body: { items: [{ mode: 'bot', id: 'c', messages: [{ id: 'first', seq: 1, version: 1 }] }] },
  154. }))
  155. })
  156. it('locates an unread target after opening and loading a different conversation', async () => {
  157. vi.mocked(apiRequest).mockImplementation(async path => {
  158. if (path.startsWith('/api/app/conversations/c')) return {
  159. conversation: { id: 'c', kind: 'direct', name: '研发助手', memberIds: [], readSeq: 0, lastSeq: 1 },
  160. messages: [{ id: 'first', role: 'assistant', status: 'complete', content: '回复', attachments: [], tools: [], createdAt: 1 }],
  161. hasOlder: false, run: null, interactions: [], context: null,
  162. } as never
  163. return successfulResponse(path) as never
  164. })
  165. await mountView('/conversations/c?unread=first', true)
  166. expect(scrollToMessage).toHaveBeenCalledWith('first')
  167. expect(wrapper!.vm.$router.currentRoute.value.query.unread).toBeUndefined()
  168. })