// @vitest-environment jsdom /** * Phase 006 §1 red line: message bubbles must not contain Arco components. * * During streaming, MessageItem re-renders on every token. Any stateful Arco * component inside the bubble adds diff work to that hot path, so bubbles stay * on native HTML plus the project's own SCSS. Overlays such as the tool-change * drawer are allowed, but only outside the bubble. * * Arco is registered globally here exactly like main.ts does, so an Arco * component leaking into the bubble renders a real .arco-* node and fails. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { defineComponent, h } from 'vue' import ArcoVue from '@arco-design/web-vue' vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, }), })) // Arco's imperative message API is the one part that cannot mount in jsdom. vi.mock('@arco-design/web-vue', async () => { const actual = await vi.importActual( '@arco-design/web-vue', ) return { ...actual, Message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), }, } }) import MessageItem from '@/components/hermes/chat/MessageItem.vue' import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue' import type { Message } from '@/stores/hermes/chat' const ARCO_CLASS = /(^|\s)arco-/ const globalConfig = { plugins: [ArcoVue] } function baseMessage(overrides: Partial): Message { return { id: 'm1', role: 'assistant', content: '', timestamp: 1_700_000_000_000, ...overrides, } as Message } /** MessageItem mounts MarkdownRenderer and ProfileAvatar; both are irrelevant here. */ const stubs = { MarkdownRenderer: { template: '
' }, ProfileAvatar: true, } function mountItem(message: Message): VueWrapper { return mount(MessageItem, { props: { message }, global: { plugins: globalConfig.plugins, stubs }, }) } function arcoDescendants(wrapper: VueWrapper, selector: string): string[] { const root = wrapper.find(selector) expect(root.exists()).toBe(true) return Array.from(root.element.querySelectorAll('*')) .map((node) => node as HTMLElement) .filter((node) => ARCO_CLASS.test(node.className || '')) .map((node) => `${node.tagName.toLowerCase()}.${node.className}`) } function stubDomGlobals() { Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true, }) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) }, }) Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: { addEventListener: vi.fn(), removeEventListener: vi.fn(), getVoices: vi.fn(() => []), speak: vi.fn(), cancel: vi.fn(), pause: vi.fn(), resume: vi.fn(), }, }) } describe('MessageItem bubble contains no Arco components (Phase 006 §1)', () => { beforeEach(() => { setActivePinia(createPinia()) stubDomGlobals() }) it('renders the assistant bubble with only native markup and project classes', () => { const wrapper = mountItem(baseMessage({ content: 'hello **world**' })) expect(arcoDescendants(wrapper, '.message-bubble')).toEqual([]) }) it('renders the user bubble with only native markup', () => { const wrapper = mountItem(baseMessage({ role: 'user', content: 'hi there' })) expect(arcoDescendants(wrapper, '.message-bubble')).toEqual([]) }) it('keeps the bubble Arco-free for a tool-role message', () => { const wrapper = mountItem( baseMessage({ role: 'tool', content: '', toolName: 'web_search', toolArgs: '{"q":"arco"}', toolResult: '{"ok":true}', }), ) expect(arcoDescendants(wrapper, '.message')).toEqual([]) }) it('keeps the bubble Arco-free across streaming re-renders', async () => { const wrapper = mountItem(baseMessage({ content: 'partial' })) for (const chunk of ['partial ans', 'partial answer', 'partial answer.']) { await wrapper.setProps({ message: baseMessage({ content: chunk }) }) expect(arcoDescendants(wrapper, '.message-bubble')).toEqual([]) } }) it('detects an Arco component that leaks into the bubble', () => { // Guards the guard: if the matcher ever stops working, this fails too. const Leaky = defineComponent({ setup() { return () => h('div', { class: 'message' }, [h('div', { class: 'message-bubble' }, [h('span', { class: 'arco-tag' })])]) }, }) const wrapper = mount(Leaky, { global: globalConfig }) expect(arcoDescendants(wrapper, '.message-bubble')).toEqual(['span.arco-tag']) }) }) describe('MarkdownRenderer preview drawer (Phase 006 §1)', () => { beforeEach(() => { setActivePinia(createPinia()) }) it('does not mount the Arco drawer until a preview is requested', () => { const wrapper = mount(MarkdownRenderer, { props: { content: '# title' }, global: globalConfig, }) expect(wrapper.find('.arco-drawer').exists()).toBe(false) }) })