- 实现暗黑模式切换逻辑,支持系统主题适配 - 优化全局样式,添加动态主题变量及暗黑样式 - 引入 `markdown-it` 和 `mermaid`,支持 Markdown 内容及流程图渲染 - 更新 ChatView 测试用例,验证 Markdown 和 Mermaid 渲染效果 - 修改 package.json,增加新依赖
694 lines
17 KiB
JavaScript
694 lines
17 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(),
|
|
},
|
|
}))
|
|
vi.mock('mermaid', () => ({
|
|
default: {
|
|
initialize: vi.fn(),
|
|
run: vi.fn().mockResolvedValue(),
|
|
},
|
|
}))
|
|
|
|
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 markdown rendering', () => {
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
vi.clearAllMocks()
|
|
latestEventSource = null
|
|
globalThis.EventSource = MockEventSource
|
|
globalThis.scrollTo = vi.fn()
|
|
})
|
|
|
|
it('renders markdown content and mermaid blocks', async () => {
|
|
localStorage.setItem('ars-token', 'token')
|
|
|
|
const router = makeRouter()
|
|
router.push('/chat/session-1')
|
|
await router.isReady()
|
|
|
|
const markdownText = ['Hello **world**', '', '```mermaid', 'graph TD;', 'A-->B;', '```'].join(
|
|
'\n'
|
|
)
|
|
const list = [
|
|
buildMessage({
|
|
seq: 1,
|
|
role: 'AGENT',
|
|
type: 'agent.message',
|
|
content: markdownText,
|
|
}),
|
|
]
|
|
|
|
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-agent')
|
|
expect(bubble.exists()).toBe(true)
|
|
|
|
const textBlock = bubble.find('.bubble-text')
|
|
expect(textBlock.html()).toContain('<strong>world</strong>')
|
|
|
|
const mermaidBlock = textBlock.find('.mermaid')
|
|
expect(mermaidBlock.exists()).toBe(true)
|
|
expect(mermaidBlock.text()).toContain('A-->B')
|
|
})
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|