Phase 006: migrate the chat page from naive-ui to Arco Design Vue
Swaps the rendering layer only. Chat store logic, keyboard handling, and the /chat-run Socket.IO contract are untouched, so a mixed UI cannot hide a chat regression behind a library swap. Arco's dropdowns take their items from a #content slot of a-doption children rather than a droplist prop, and Arco Triggers cannot be nested, so the two toolbar menus lost their a-tooltip in favour of a native title. Both rules are recorded in docs/arco-mapping.md, and tests/e2e/chat-input-dropdown.spec.ts plus tests/e2e/chat-rendering-smoke.spec.ts keep the menus, the streaming markdown rendering, and the no-Arco-inside-the-bubble red line from regressing silently.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// @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<typeof import('@arco-design/web-vue')>(
|
||||
'@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>): 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: '<div class="markdown-body" />' },
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user