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