| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142 |
- import { mount } from '@vue/test-utils'
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import { defineComponent, nextTick, type Component } from 'vue'
- import TypingIndicator from '@/components/common/TypingIndicator.vue'
- import MessageTimeline from '@/components/messages/MessageTimeline.vue'
- import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
- import type { UiMessage } from '@/components/messages/types'
- const AgentAvatarStub = defineComponent({
- props: {
- name: { type: String, required: true },
- avatar: { type: String, default: '' },
- size: { type: Number, required: true },
- state: { type: String, required: true },
- },
- template: `<span class="agent-avatar-stub" :data-name="name" :data-avatar="avatar" :data-size="size" :data-state="state" />`,
- })
- const stubs = {
- AgentAvatar: AgentAvatarStub,
- EmptyState: true,
- InteractionCard: true,
- MarkdownContent: true,
- ToolTrace: true,
- TurnTrace: true,
- WorkspaceApprovalCard: true,
- }
- const user: UiMessage = { id: 'user', role: 'user', content: '请继续' }
- function streamingAssistant(): UiMessage {
- return {
- id: 'assistant-streaming',
- role: 'assistant',
- author: '流式助手',
- profile: 'streaming-profile',
- content: '正文已经开始输出',
- reasoning: '思考也已经出现',
- status: 'streaming',
- tools: [{ id: 'tool', name: 'read_file', status: 'running' }],
- }
- }
- function mountTimeline(component: Component, messages: UiMessage[], thinking: boolean) {
- return mount(component, {
- props: {
- ...(component === WorkspaceMessageTimeline ? { identity: 'timeline' } : {}),
- messages,
- thinking,
- agentAvatars: {
- 'streaming-profile': 'avatar-streaming',
- 'history-profile': 'avatar-history',
- },
- },
- global: { stubs },
- })
- }
- describe('TypingIndicator', () => {
- it('renders a compact avatar, three dots and status semantics', () => {
- const wrapper = mount(TypingIndicator, {
- props: { avatarName: '丫头', avatar: '' },
- })
- expect(wrapper.attributes('role')).toBe('status')
- expect(wrapper.attributes('aria-live')).toBe('polite')
- expect(wrapper.attributes('aria-label')).toBe('机器人正在输入')
- expect(wrapper.attributes('data-testid')).toBe('chat-run-thinking-dots')
- expect(wrapper.get('.agent-avatar').attributes('style')).toContain('width: 20px')
- expect(wrapper.findAll('.thinking-indicator__dot')).toHaveLength(3)
- expect(wrapper.text()).not.toMatch(/\d+ 秒/)
- wrapper.unmount()
- })
- })
- describe('message timeline typing state', () => {
- const timelines: Array<{ name: string; component: Component }> = [
- { name: 'ordinary', component: MessageTimeline },
- { name: 'workspace', component: WorkspaceMessageTimeline },
- ]
- beforeEach(() => {
- Element.prototype.scrollTo = vi.fn()
- vi.stubGlobal('ResizeObserver', class {
- observe() {}
- unobserve() {}
- disconnect() {}
- })
- })
- afterEach(() => {
- delete (Element.prototype as { scrollTo?: () => void }).scrollTo
- vi.unstubAllGlobals()
- vi.useRealTimers()
- })
- it.each(timelines)('keeps the typing row visible after content, reasoning and tools start ($name)', async ({ component }) => {
- const wrapper = mountTimeline(component, [user, streamingAssistant()], true)
- const indicator = wrapper.get('[data-testid="chat-run-thinking-dots"]')
- expect(indicator.attributes('role')).toBe('status')
- expect(indicator.attributes('aria-label')).toBe('机器人正在输入')
- expect(wrapper.findAll('.thinking-indicator__dot')).toHaveLength(3)
- expect(wrapper.text()).not.toMatch(/\d+ 秒/)
- const avatar = wrapper.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub')
- expect(avatar.attributes('data-name')).toBe('流式助手')
- expect(avatar.attributes('data-avatar')).toBe('avatar-streaming')
- wrapper.unmount()
- })
- it.each(timelines)('hides the typing row when the run is no longer thinking ($name)', ({ component }) => {
- const wrapper = mountTimeline(component, [user, streamingAssistant()], false)
- expect(wrapper.find('[data-testid="chat-run-thinking-dots"]').exists()).toBe(false)
- wrapper.unmount()
- })
- it.each(timelines)('falls back from streaming agent to history agent and default agent ($name)', ({ component }) => {
- const streaming = mountTimeline(component, [
- user,
- { id: 'assistant-history', role: 'assistant', author: '历史助手', profile: 'history-profile', content: '旧回复' },
- streamingAssistant(),
- ], true)
- expect(streaming.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('流式助手')
- streaming.unmount()
- const history = mountTimeline(component, [
- user,
- { id: 'assistant-history', role: 'assistant', author: '历史助手', profile: 'history-profile', content: '旧回复' },
- ], true)
- expect(history.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('历史助手')
- expect(history.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-avatar')).toBe('avatar-history')
- history.unmount()
- const fallback = mountTimeline(component, [user], true)
- expect(fallback.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('夭')
- expect(fallback.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-avatar')).toBe('')
- fallback.unmount()
- })
- })
|