conversationRecovery.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. import { afterEach, beforeEach, 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. vi.mock('@/api/client', async importOriginal => ({ ...await importOriginal<typeof import('@/api/client')>(), apiRequest: vi.fn(), setApiCsrfToken: vi.fn() }))
  9. vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner' }, profiles: [], refreshProfileAvatars: async () => {} }) }))
  10. vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({}) }))
  11. let wrapper: VueWrapper | undefined
  12. const sources: Array<{ url: string; close: ReturnType<typeof vi.fn> }> = []
  13. beforeEach(() => {
  14. vi.useFakeTimers()
  15. sources.length = 0
  16. vi.stubGlobal('EventSource', class extends EventTarget {
  17. readonly close = vi.fn()
  18. constructor(readonly url: string) { super(); sources.push(this) }
  19. })
  20. })
  21. afterEach(() => {
  22. wrapper?.unmount(); wrapper = undefined
  23. vi.mocked(apiRequest).mockReset(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers()
  24. })
  25. async function mountView(path = '/conversations') {
  26. const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/:id?', component: { template: '<div />' } }] })
  27. await router.push(path); await router.isReady()
  28. wrapper = shallowMount(ConversationsView, { global: {
  29. provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) }, plugins: [router],
  30. stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [] } }, Teleport: true },
  31. } })
  32. await flushPromises()
  33. }
  34. function successfulResponse(path: string) {
  35. if (path === '/api/app/capabilities') return { features: [] }
  36. if (path === '/api/app/workspace/snapshot') return { agents: [], conversations: [], details: [], cursor: 17 }
  37. throw new Error(`Unexpected request ${path}`)
  38. }
  39. it.each([undefined, null, { agents: [], conversations: [], details: [] }])('recovers incomplete Bot initialization data without requiring a virtual machine', async snapshot => {
  40. let snapshots = 0
  41. vi.mocked(apiRequest).mockImplementation(async path => {
  42. if (path === '/api/app/workspace/snapshot' && snapshots++ === 0) return snapshot as never
  43. return successfulResponse(path) as never
  44. })
  45. await mountView()
  46. expect(sources).toHaveLength(0)
  47. expect(wrapper!.get('[role="alert"]').text()).toContain('Bot 会话数据尚未就绪')
  48. expect(wrapper!.text()).not.toContain('TypeError')
  49. await vi.advanceTimersByTimeAsync(1500); await flushPromises()
  50. expect(sources.map(source => source.url)).toEqual(['/api/app/events/stream?after=17'])
  51. expect(vi.mocked(apiRequest).mock.calls.some(([path]) => /local-vm|computer/.test(path))).toBe(false)
  52. })
  53. it('recovers an initial server outage while the browser stays online', async () => {
  54. vi.mocked(apiRequest).mockRejectedValueOnce(new Error('服务暂不可用')).mockImplementation(async path => successfulResponse(path) as never)
  55. await mountView()
  56. expect(sources).toHaveLength(0)
  57. await vi.advanceTimersByTimeAsync(1500); await flushPromises()
  58. expect(sources.map(source => source.url)).toEqual(['/api/app/events/stream?after=17'])
  59. expect(vi.mocked(apiRequest).mock.calls.filter(([path]) => path === '/api/app/capabilities')).toHaveLength(2)
  60. })
  61. it('cancels a scheduled initial recovery when the view is destroyed', async () => {
  62. vi.mocked(apiRequest).mockRejectedValue(new Error('服务暂不可用'))
  63. await mountView()
  64. wrapper!.unmount(); wrapper = undefined
  65. await vi.advanceTimersByTimeAsync(10_000)
  66. expect(apiRequest).toHaveBeenCalledTimes(1)
  67. expect(sources).toHaveLength(0)
  68. })
  69. it('aborts an in-flight snapshot and prevents a late response from connecting after destruction', async () => {
  70. let signal: AbortSignal | undefined, release!: (value: unknown) => void
  71. vi.mocked(apiRequest).mockImplementation(async (path, options) => {
  72. if (path === '/api/app/capabilities') return successfulResponse(path) as never
  73. signal = options?.signal as AbortSignal
  74. return new Promise(resolve => { release = resolve })
  75. })
  76. await mountView()
  77. expect(signal?.aborted).toBe(false)
  78. wrapper!.unmount(); wrapper = undefined
  79. expect(signal?.aborted).toBe(true)
  80. release(successfulResponse('/api/app/workspace/snapshot'))
  81. await flushPromises(); await vi.advanceTimersByTimeAsync(10_000)
  82. expect(sources).toHaveLength(0)
  83. expect(apiRequest).toHaveBeenCalledTimes(2)
  84. })
  85. it('cancels recovery while offline and restarts it only when connectivity returns', async () => {
  86. let online = true
  87. vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => online)
  88. vi.mocked(apiRequest).mockRejectedValueOnce(new Error('服务暂不可用')).mockImplementation(async path => successfulResponse(path) as never)
  89. await mountView()
  90. online = false; window.dispatchEvent(new Event('offline'))
  91. await vi.advanceTimersByTimeAsync(10_000)
  92. expect(apiRequest).toHaveBeenCalledTimes(1)
  93. online = true; window.dispatchEvent(new Event('online')); await flushPromises()
  94. expect(sources.map(source => source.url)).toEqual(['/api/app/events/stream?after=17'])
  95. })
  96. it('aborts a pending detail on going offline and ignores its late response', async () => {
  97. let online = true, signal: AbortSignal | undefined, release!: (value: unknown) => void
  98. vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => online)
  99. vi.mocked(apiRequest).mockImplementation(async (path, options) => {
  100. if (!path.startsWith('/api/app/conversations/')) return successfulResponse(path) as never
  101. signal = options?.signal as AbortSignal
  102. return new Promise(resolve => { release = resolve })
  103. })
  104. await mountView('/conversations/c')
  105. expect(signal?.aborted).toBe(false)
  106. online = false; window.dispatchEvent(new Event('offline'))
  107. expect(signal?.aborted).toBe(true)
  108. release({ conversation: { id: 'c', kind: 'direct', name: '迟到会话', memberIds: [], readSeq: 0, lastSeq: 0 }, messages: [], run: null, interactions: [], context: null })
  109. await flushPromises(); await vi.advanceTimersByTimeAsync(5000)
  110. expect(wrapper!.findComponent(WorkspaceMessageTimeline).props('title')).not.toBe('迟到会话')
  111. expect(sources).toHaveLength(0)
  112. })