typingIndicator.test.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. import { mount } from '@vue/test-utils'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { defineComponent, nextTick, type Component } from 'vue'
  4. import TypingIndicator from '@/components/common/TypingIndicator.vue'
  5. import MessageTimeline from '@/components/messages/MessageTimeline.vue'
  6. import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
  7. import type { UiMessage } from '@/components/messages/types'
  8. const AgentAvatarStub = defineComponent({
  9. props: {
  10. name: { type: String, required: true },
  11. avatar: { type: String, default: '' },
  12. size: { type: Number, required: true },
  13. state: { type: String, required: true },
  14. },
  15. template: `<span class="agent-avatar-stub" :data-name="name" :data-avatar="avatar" :data-size="size" :data-state="state" />`,
  16. })
  17. const stubs = {
  18. AgentAvatar: AgentAvatarStub,
  19. EmptyState: true,
  20. InteractionCard: true,
  21. MarkdownContent: true,
  22. ToolTrace: true,
  23. TurnTrace: true,
  24. WorkspaceApprovalCard: true,
  25. }
  26. const user: UiMessage = { id: 'user', role: 'user', content: '请继续' }
  27. function streamingAssistant(): UiMessage {
  28. return {
  29. id: 'assistant-streaming',
  30. role: 'assistant',
  31. author: '流式助手',
  32. profile: 'streaming-profile',
  33. content: '正文已经开始输出',
  34. reasoning: '思考也已经出现',
  35. status: 'streaming',
  36. tools: [{ id: 'tool', name: 'read_file', status: 'running' }],
  37. }
  38. }
  39. function mountTimeline(component: Component, messages: UiMessage[], thinking: boolean) {
  40. return mount(component, {
  41. props: {
  42. ...(component === WorkspaceMessageTimeline ? { identity: 'timeline' } : {}),
  43. messages,
  44. thinking,
  45. agentAvatars: {
  46. 'streaming-profile': 'avatar-streaming',
  47. 'history-profile': 'avatar-history',
  48. },
  49. },
  50. global: { stubs },
  51. })
  52. }
  53. describe('TypingIndicator', () => {
  54. it('renders a compact avatar, three dots, elapsed time and status semantics', () => {
  55. const wrapper = mount(TypingIndicator, {
  56. props: { avatarName: '丫头', avatar: '', elapsedLabel: '3 秒' },
  57. })
  58. expect(wrapper.attributes('role')).toBe('status')
  59. expect(wrapper.attributes('aria-live')).toBe('polite')
  60. expect(wrapper.attributes('aria-label')).toBe('机器人正在输入')
  61. expect(wrapper.attributes('data-testid')).toBe('chat-run-thinking-dots')
  62. expect(wrapper.get('.agent-avatar').attributes('style')).toContain('width: 20px')
  63. expect(wrapper.findAll('.thinking-indicator__dot')).toHaveLength(3)
  64. expect(wrapper.text()).toContain('3 秒')
  65. wrapper.unmount()
  66. })
  67. })
  68. describe('message timeline typing state', () => {
  69. const timelines: Array<{ name: string; component: Component }> = [
  70. { name: 'ordinary', component: MessageTimeline },
  71. { name: 'workspace', component: WorkspaceMessageTimeline },
  72. ]
  73. beforeEach(() => {
  74. Element.prototype.scrollTo = vi.fn()
  75. vi.stubGlobal('ResizeObserver', class {
  76. observe() {}
  77. unobserve() {}
  78. disconnect() {}
  79. })
  80. })
  81. afterEach(() => {
  82. delete (Element.prototype as { scrollTo?: () => void }).scrollTo
  83. vi.unstubAllGlobals()
  84. vi.useRealTimers()
  85. })
  86. it.each(timelines)('keeps the typing row visible after content, reasoning and tools start ($name)', async ({ component }) => {
  87. const wrapper = mountTimeline(component, [user, streamingAssistant()], true)
  88. const indicator = wrapper.get('[data-testid="chat-run-thinking-dots"]')
  89. expect(indicator.attributes('role')).toBe('status')
  90. expect(indicator.attributes('aria-label')).toBe('机器人正在输入')
  91. expect(wrapper.findAll('.thinking-indicator__dot')).toHaveLength(3)
  92. expect(wrapper.text()).toContain('0 秒')
  93. const avatar = wrapper.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub')
  94. expect(avatar.attributes('data-name')).toBe('流式助手')
  95. expect(avatar.attributes('data-avatar')).toBe('avatar-streaming')
  96. wrapper.unmount()
  97. })
  98. it.each(timelines)('hides the typing row when the run is no longer thinking ($name)', ({ component }) => {
  99. const wrapper = mountTimeline(component, [user, streamingAssistant()], false)
  100. expect(wrapper.find('[data-testid="chat-run-thinking-dots"]').exists()).toBe(false)
  101. wrapper.unmount()
  102. })
  103. it.each(timelines)('falls back from streaming agent to history agent and default agent ($name)', ({ component }) => {
  104. const streaming = mountTimeline(component, [
  105. user,
  106. { id: 'assistant-history', role: 'assistant', author: '历史助手', profile: 'history-profile', content: '旧回复' },
  107. streamingAssistant(),
  108. ], true)
  109. expect(streaming.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('流式助手')
  110. streaming.unmount()
  111. const history = mountTimeline(component, [
  112. user,
  113. { id: 'assistant-history', role: 'assistant', author: '历史助手', profile: 'history-profile', content: '旧回复' },
  114. ], true)
  115. expect(history.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('历史助手')
  116. expect(history.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-avatar')).toBe('avatar-history')
  117. history.unmount()
  118. const fallback = mountTimeline(component, [user], true)
  119. expect(fallback.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('夭')
  120. expect(fallback.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-avatar')).toBe('')
  121. fallback.unmount()
  122. })
  123. it('counts elapsed time while thinking remains active', async () => {
  124. vi.useFakeTimers()
  125. const wrapper = mountTimeline(MessageTimeline, [user, streamingAssistant()], true)
  126. expect(wrapper.text()).toContain('0 秒')
  127. vi.advanceTimersByTime(1_100)
  128. await nextTick()
  129. expect(wrapper.text()).toContain('1 秒')
  130. wrapper.unmount()
  131. })
  132. })