完善 ChatView 页面功能与用户交互逻辑
- 增加请求状态管理,区分 RUNNING 和 DONE 状态 - 优化消息列表归一化处理逻辑,提高代码可读性 - 添加按键事件支持 (Ctrl+Enter) 以改善发送消息体验 - 更新输入框与发送按钮的禁用逻辑,明确状态反馈 - 提升 SSE 连接恢复的安全性,增加鉴权检查 - 优化界面显示,新增快捷键提示及样式改进 - 移除 package-lock.json 中多余的 peer 标记
This commit is contained in:
267
tests/chat-view.spec.js
Normal file
267
tests/chat-view.spec.js
Normal file
@@ -0,0 +1,267 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
62
tests/login.spec.js
Normal file
62
tests/login.spec.js
Normal file
@@ -0,0 +1,62 @@
|
||||
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 LoginView from '../src/views/LoginView.vue'
|
||||
import axios from 'axios'
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedAxios = axios
|
||||
|
||||
const makeRouter = () =>
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'login', component: LoginView },
|
||||
{ path: '/welcome', name: 'welcome', component: LoginView },
|
||||
],
|
||||
})
|
||||
|
||||
describe('LoginView enter submit', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('submits login on form submit', async () => {
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { token: 'token-123', user: { id: 'u1' } },
|
||||
})
|
||||
|
||||
const router = makeRouter()
|
||||
router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [router, ElementPlus],
|
||||
},
|
||||
})
|
||||
|
||||
const emailInput = wrapper.find('input[autocomplete="email"]')
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]'
|
||||
)
|
||||
await emailInput.setValue('user@example.com')
|
||||
await passwordInput.setValue('password')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/api/login',
|
||||
{ email: 'user@example.com', password: 'password' }
|
||||
)
|
||||
expect(router.currentRoute.value.name).toBe('welcome')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user