Files
Ars-Font/tests/chat-view.spec.js
Roog afb166dddf 完善 ChatView 页面功能与用户交互逻辑
- 增加请求状态管理,区分 RUNNING 和 DONE 状态
- 优化消息列表归一化处理逻辑,提高代码可读性
- 添加按键事件支持 (Ctrl+Enter) 以改善发送消息体验
- 更新输入框与发送按钮的禁用逻辑,明确状态反馈
- 提升 SSE 连接恢复的安全性,增加鉴权检查
- 优化界面显示,新增快捷键提示及样式改进
- 移除 package-lock.json 中多余的 peer 标记
2025-12-18 17:50:02 +08:00

268 lines
6.7 KiB
JavaScript

import { describe, it, expect, vi, beforeEach } 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')
})
})