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: '
' } 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('world') 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') }) })