Files
Ars-Font/tests/chat-view.spec.js
ROOG 9b48ff3e3b main: 增强 ChatView 消息处理与错误展示功能
- 调整消息归一化逻辑,添加对 delta 和 agent.message 的支持
- 实现长文本逐字显示效果,增强用户交互体验
- 新增系统错误展示机制,支持查看原始错误信息
- 改善状态更新逻辑,优化 FAILED 状态的处理
- 优化界面样式,增加系统消息气泡与错误卡片设计
2025-12-19 02:35:07 +08:00

627 lines
16 KiB
JavaScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ElementPlus from 'element-plus'
import { nextTick } from 'vue'
import ChatView from '../src/views/ChatView.vue'
import axios from 'axios'
vi.mock('axios', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}))
const mockedAxios = axios
let latestEventSource = null
class MockEventSource {
constructor() {
latestEventSource = this
this.onmessage = null
this.onerror = null
}
close() {}
}
const LoginStub = { template: '<div />' }
const makeRouter = () =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/chat/:id', name: 'chat', component: ChatView },
{ path: '/login', name: 'login', component: LoginStub },
],
})
const buildMessage = (overrides = {}) => ({
message_id: 'msg-1',
session_id: 'session-1',
seq: 1,
role: 'USER',
type: 'user.prompt',
content: 'hello',
payload: null,
reply_to: null,
dedupe_key: null,
created_at: '2025-12-18T05:30:51.000000Z',
...overrides,
})
describe('ChatView run.status handling', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
latestEventSource = null
globalThis.EventSource = MockEventSource
globalThis.scrollTo = vi.fn()
})
it('locks input and filters run.status messages on initial load', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
const list = [
buildMessage({ seq: 1, role: 'USER', type: 'user.prompt', content: 'hi' }),
buildMessage({
seq: 2,
role: 'SYSTEM',
type: 'run.status',
content: 'RUNNING',
payload: { status: 'RUNNING' },
}),
buildMessage({
seq: 3,
role: 'AGENT',
type: 'agent.message',
content: 'reply',
}),
]
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 3 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: list } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const bubbles = wrapper.findAll('.bubble')
expect(bubbles).toHaveLength(2)
const sendButton = wrapper.find('button.send-btn')
expect(sendButton.element.disabled).toBe(true)
const textarea = wrapper.find('textarea')
expect(textarea.element.disabled).toBe(false)
})
it('unlocks input when run.status DONE arrives via sse', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
const list = [
buildMessage({ seq: 1, role: 'USER', type: 'user.prompt', content: 'hi' }),
buildMessage({
seq: 2,
role: 'SYSTEM',
type: 'run.status',
content: 'RUNNING',
payload: { status: 'RUNNING' },
}),
]
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 2 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: list } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const sendButton = wrapper.find('button.send-btn')
expect(sendButton.element.disabled).toBe(true)
const textarea = wrapper.find('textarea')
expect(textarea.element.disabled).toBe(false)
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 3,
role: 'SYSTEM',
type: 'run.status',
content: 'DONE',
payload: { status: 'DONE' },
})
),
})
await flushPromises()
expect(sendButton.element.disabled).toBe(false)
expect(textarea.element.disabled).toBe(false)
})
it('sends message on ctrl+enter', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 0 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: [] } })
}
return Promise.resolve({ data: {} })
})
mockedAxios.post.mockResolvedValue({ data: {} })
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const textarea = wrapper.find('textarea')
await textarea.setValue('hello')
await textarea.trigger('keydown', { key: 'Enter', ctrlKey: true })
await flushPromises()
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://localhost:8000/api/sessions/session-1/messages',
expect.objectContaining({
role: 'USER',
type: 'user.prompt',
content: 'hello',
}),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer token',
}),
})
)
})
it('redirects to login when sse error returns 401', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 0 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: [] } })
}
if (url.endsWith('/me')) {
return Promise.reject({ response: { status: 401 } })
}
return Promise.resolve({ data: {} })
})
mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
expect(latestEventSource).not.toBeNull()
latestEventSource.onerror()
await flushPromises()
expect(router.currentRoute.value.name).toBe('login')
})
})
describe('ChatView message.delta handling', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
latestEventSource = null
globalThis.EventSource = MockEventSource
globalThis.scrollTo = vi.fn()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('hides message.delta when agent.message exists in history', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
const list = [
buildMessage({ seq: 1, role: 'USER', type: 'user.prompt', content: 'hi' }),
buildMessage({
seq: 2,
role: 'AGENT',
type: 'message.delta',
content: 'partial',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:1',
}),
buildMessage({
seq: 3,
role: 'AGENT',
type: 'agent.message',
content: 'final answer',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:final:1',
}),
]
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 3 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: list } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const bubbles = wrapper.findAll('.bubble')
expect(bubbles).toHaveLength(2)
const texts = wrapper.findAll('.bubble-text').map((node) => node.text())
expect(texts).toEqual(['hi', 'final answer'])
})
it('appends message.delta content and replaces with agent.message on sse', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 0 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: [] } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 1,
role: 'AGENT',
type: 'message.delta',
content: 'hello',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:1',
})
),
})
await flushPromises()
await nextTick()
expect(wrapper.findAll('.bubble')).toHaveLength(1)
expect(wrapper.find('.bubble-text').text()).not.toBe('hello')
vi.runAllTimers()
await nextTick()
let bubbles = wrapper.findAll('.bubble-text')
expect(bubbles[bubbles.length - 1].text()).toBe('hello')
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 2,
role: 'AGENT',
type: 'message.delta',
content: ' world',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:2',
})
),
})
await flushPromises()
await nextTick()
bubbles = wrapper.findAll('.bubble-text')
expect(bubbles[bubbles.length - 1].text()).toBe('hello')
vi.runAllTimers()
await nextTick()
expect(wrapper.find('.bubble-text').text()).toBe('hello world')
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 2,
role: 'AGENT',
type: 'message.delta',
content: '!!!',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:2',
})
),
})
await flushPromises()
await nextTick()
expect(wrapper.find('.bubble-text').text()).toBe('hello world')
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 3,
role: 'AGENT',
type: 'agent.message',
content: 'final',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:final:1',
})
),
})
await flushPromises()
await nextTick()
expect(wrapper.findAll('.bubble')).toHaveLength(1)
expect(wrapper.find('.bubble-text').text()).toBe('final')
})
})
describe('ChatView system error and failed status handling', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
latestEventSource = null
globalThis.EventSource = MockEventSource
globalThis.scrollTo = vi.fn()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('shows system error with payload toggle', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
const list = [
buildMessage({
seq: 1,
role: 'SYSTEM',
type: 'error',
content: 'HTTP_ERROR',
payload: {
run_id: 'run-err',
message: 'Agent provider failed',
raw_message: '{"error":"oops"}',
},
dedupe_key: 'run-err:error',
}),
]
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 1 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: list } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const bubble = wrapper.find('.bubble-system')
expect(bubble.exists()).toBe(true)
expect(bubble.text()).toContain('Agent provider failed')
const toggle = wrapper.find('.payload-toggle')
expect(toggle.exists()).toBe(true)
await toggle.trigger('click')
await nextTick()
expect(wrapper.find('.payload-json').text()).toContain('raw_message')
})
it('unlocks and finalizes on FAILED run.status', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
const list = [
buildMessage({ seq: 1, role: 'USER', type: 'user.prompt', content: 'hi' }),
buildMessage({
seq: 2,
role: 'SYSTEM',
type: 'run.status',
content: 'RUNNING',
payload: { status: 'RUNNING', run_id: 'run-1' },
}),
]
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 2 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: list } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const sendButton = wrapper.find('button.send-btn')
expect(sendButton.element.disabled).toBe(true)
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 3,
role: 'AGENT',
type: 'message.delta',
content: 'hello',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:1',
})
),
})
await flushPromises()
vi.runAllTimers()
await nextTick()
const getLastBubbleText = () => {
const bubbles = wrapper.findAll('.bubble-text')
return bubbles[bubbles.length - 1]?.text()
}
expect(getLastBubbleText()).toBe('hello')
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 4,
role: 'SYSTEM',
type: 'run.status',
content: null,
payload: { status: 'FAILED', run_id: 'run-1' },
dedupe_key: 'run-1:failed',
})
),
})
await flushPromises()
await nextTick()
expect(sendButton.element.disabled).toBe(false)
latestEventSource.onmessage({
data: JSON.stringify(
buildMessage({
seq: 5,
role: 'AGENT',
type: 'message.delta',
content: ' more',
payload: { run_id: 'run-1' },
dedupe_key: 'run-1:delta:2',
})
),
})
await flushPromises()
vi.runAllTimers()
await nextTick()
expect(getLastBubbleText()).toBe('hello')
})
})