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.
283 lines
9.6 KiB
TypeScript
283 lines
9.6 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||
import { flushPromises, mount } from '@vue/test-utils'
|
||
import { createTestingPinia } from '@pinia/testing'
|
||
import { nextTick } from 'vue'
|
||
import ArcoVue, { Message } from '@arco-design/web-vue'
|
||
import { useChatStore } from '@/stores/hermes/chat'
|
||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||
import ChatInput from '@/components/hermes/chat/ChatInput.vue'
|
||
|
||
const fetchSkillsMock = vi.hoisted(() => vi.fn())
|
||
|
||
vi.mock('vue-i18n', () => ({
|
||
useI18n: () => ({ t: (key: string) => key }),
|
||
}))
|
||
|
||
// Arco's imperative message API needs a live DOM; the rest runs for real.
|
||
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(),
|
||
},
|
||
}
|
||
})
|
||
|
||
/**
|
||
* a-modal / a-dropdown teleport to body in jsdom, so stub them to render inline.
|
||
* Arco is registered globally, so stubs must key off Arco's internal component
|
||
* name (`Modal`, `Dropdown`), not the `a-*` template tag.
|
||
*/
|
||
const AModalStub = {
|
||
name: 'Modal',
|
||
props: ['visible'],
|
||
template: '<div v-if="visible" class="a-modal-stub"><slot /><slot name="footer" /></div>',
|
||
}
|
||
|
||
/**
|
||
* Arco 的 a-dropdown 通过 #content 插槽 + a-doption 声明菜单,没有 droplist prop。
|
||
* 这里把 content 插槽内联渲染出来,好让测试直接点到某个推理档位;真实 Arco 里
|
||
* 是 Doption 通过 inject 调 dropdown 的 onOptionClick,stub 用事件冒泡等价实现。
|
||
*/
|
||
const ADropdownStub = {
|
||
name: 'Dropdown',
|
||
emits: ['select'],
|
||
methods: {
|
||
forwardSelect(event: MouseEvent) {
|
||
const target = (event.target as HTMLElement | null)?.closest('[data-value]')
|
||
const value = target?.getAttribute('data-value')
|
||
if (value !== undefined && value !== null) this.$emit('select', value)
|
||
},
|
||
},
|
||
template: `
|
||
<div class="a-dropdown-stub" @click="forwardSelect">
|
||
<slot />
|
||
<slot name="content" />
|
||
</div>
|
||
`,
|
||
}
|
||
|
||
const ADoptionStub = {
|
||
name: 'Doption',
|
||
props: ['value'],
|
||
template: '<button type="button" class="a-dropdown-option" :data-value="value"><slot /></button>',
|
||
}
|
||
|
||
vi.mock('@/api/hermes/sessions', () => ({
|
||
fetchContextLength: vi.fn().mockResolvedValue(256000),
|
||
}))
|
||
|
||
vi.mock('@/api/hermes/model-context', () => ({
|
||
setModelContext: vi.fn().mockResolvedValue(undefined),
|
||
}))
|
||
|
||
vi.mock('@/api/hermes/skills', () => ({
|
||
fetchSkills: fetchSkillsMock,
|
||
}))
|
||
|
||
vi.mock('@/composables/useToolTraceVisibility', () => ({
|
||
useToolTraceVisibility: () => ({ toolTraceVisible: { value: true }, toggleToolTraceVisible: vi.fn() }),
|
||
}))
|
||
|
||
function mountForSession(
|
||
sessionId: string,
|
||
sessionOverrides: Partial<ReturnType<typeof useChatStore>['sessions'][number]> = {},
|
||
displayOverrides: Record<string, any> = {},
|
||
) {
|
||
const pinia = createTestingPinia({ stubActions: false, createSpy: vi.fn })
|
||
const chatStore = useChatStore()
|
||
const settingsStore = useSettingsStore()
|
||
chatStore.sessions = [
|
||
{ id: sessionId, title: sessionId, source: 'cli', messages: [], createdAt: Date.now(), updatedAt: Date.now(), ...sessionOverrides },
|
||
]
|
||
chatStore.activeSessionId = sessionId
|
||
chatStore.activeSession = chatStore.sessions[0]
|
||
settingsStore.display = displayOverrides
|
||
return mount(ChatInput, {
|
||
global: {
|
||
plugins: [pinia, ArcoVue],
|
||
// Overlays teleport in jsdom, so render dropdown and modal inline.
|
||
stubs: { Dropdown: ADropdownStub, Doption: ADoptionStub, Modal: AModalStub },
|
||
},
|
||
})
|
||
}
|
||
|
||
describe('ChatInput draft persistence', () => {
|
||
beforeEach(() => {
|
||
localStorage.clear()
|
||
window.innerWidth = 1024
|
||
fetchSkillsMock.mockReset()
|
||
fetchSkillsMock.mockResolvedValue({ categories: [], archived: [] })
|
||
})
|
||
|
||
it('restores unsent text for the active session after the chat view is remounted', async () => {
|
||
const wrapper = mountForSession('session-a')
|
||
const textarea = wrapper.get('textarea')
|
||
|
||
await textarea.setValue('draft before tab switch')
|
||
await nextTick()
|
||
wrapper.unmount()
|
||
|
||
const remounted = mountForSession('session-a')
|
||
await nextTick()
|
||
|
||
expect((remounted.get('textarea').element as HTMLTextAreaElement).value).toBe('draft before tab switch')
|
||
})
|
||
|
||
it('stores drafts under one localStorage key mapped by session id', async () => {
|
||
const wrapperA = mountForSession('session-a')
|
||
await wrapperA.get('textarea').setValue('draft for session a')
|
||
await nextTick()
|
||
wrapperA.unmount()
|
||
|
||
const wrapperB = mountForSession('session-b')
|
||
await wrapperB.get('textarea').setValue('draft for session b')
|
||
await nextTick()
|
||
wrapperB.unmount()
|
||
|
||
expect(localStorage.getItem('hermes_chat_input_draft_v1')).toBeNull()
|
||
expect(JSON.parse(localStorage.getItem('hermes_chat_input_drafts_v1') || '{}')).toEqual({
|
||
'session-a': 'draft for session a',
|
||
'session-b': 'draft for session b',
|
||
})
|
||
|
||
const remountedA = mountForSession('session-a')
|
||
await nextTick()
|
||
expect((remountedA.get('textarea').element as HTMLTextAreaElement).value).toBe('draft for session a')
|
||
})
|
||
|
||
it('applies the configured desktop input height from display settings', async () => {
|
||
const wrapper = mountForSession('session-a', {}, { chat_input_height: 180 })
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).style.height).toBe('180px')
|
||
expect((wrapper.get('.input-wrapper').element as HTMLElement).style.minHeight).toBe('251px')
|
||
})
|
||
|
||
it('applies display setting changes after a manual resize', async () => {
|
||
const wrapper = mountForSession('session-a')
|
||
const settingsStore = useSettingsStore()
|
||
const resizeHandle = wrapper.get('.resize-handle')
|
||
|
||
await resizeHandle.trigger('mousedown', { clientY: 100 })
|
||
document.dispatchEvent(new MouseEvent('mousemove', { clientY: 50 }))
|
||
document.dispatchEvent(new MouseEvent('mouseup'))
|
||
await nextTick()
|
||
|
||
settingsStore.display.chat_input_height = 220
|
||
await nextTick()
|
||
|
||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).style.height).toBe('220px')
|
||
expect((wrapper.get('.input-wrapper').element as HTMLElement).style.minHeight).toBe('291px')
|
||
})
|
||
|
||
it('keeps mobile chat input behavior even when a desktop height is configured', async () => {
|
||
window.innerWidth = 640
|
||
const wrapper = mountForSession('session-mobile', {}, { chat_input_height: 180 })
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).style.height).not.toBe('180px')
|
||
})
|
||
|
||
it('shows context usage for the active session', async () => {
|
||
const wrapper = mountForSession('session-context', {
|
||
inputTokens: 1200,
|
||
outputTokens: 800,
|
||
contextTokens: 2000,
|
||
})
|
||
await nextTick()
|
||
|
||
expect(wrapper.find('.context-info').exists()).toBe(true)
|
||
expect(wrapper.find('.context-info').text()).toContain('2.0k')
|
||
expect(wrapper.find('.context-bar').exists()).toBe(true)
|
||
})
|
||
|
||
it('shows the reasoning effort selector for the active session', async () => {
|
||
const wrapper = mountForSession('session-reasoning-effort')
|
||
await nextTick()
|
||
|
||
expect(wrapper.find('.reasoning-effort-button').exists()).toBe(true)
|
||
expect(
|
||
wrapper.find('.reasoning-effort-button').element.closest('.a-dropdown-stub'),
|
||
).not.toBeNull()
|
||
})
|
||
|
||
it('stores the selected reasoning effort for the active session', async () => {
|
||
const wrapper = mountForSession('session-reasoning')
|
||
const store = useChatStore()
|
||
|
||
const effortDropdown = wrapper
|
||
.find('.reasoning-effort-button')
|
||
.element.closest('.a-dropdown-stub')
|
||
expect(effortDropdown).not.toBeNull()
|
||
|
||
const effortOptions = wrapper.findAll('.a-dropdown-stub [data-value="high"]')
|
||
expect(effortOptions.length).toBeGreaterThan(0)
|
||
await effortOptions[0].trigger('click')
|
||
await nextTick()
|
||
|
||
expect(store.sessions[0].reasoningEffort).toBe('high')
|
||
expect(localStorage.getItem('hermes:reasoning_effort:session-reasoning')).toBe('high')
|
||
})
|
||
|
||
it('opens the skill picker from /skill and inserts the selected skill command', async () => {
|
||
fetchSkillsMock.mockResolvedValue({
|
||
categories: [
|
||
{
|
||
name: 'review',
|
||
description: '',
|
||
skills: [
|
||
{ name: 'github-pr-review', description: 'Review pull requests', enabled: true },
|
||
{ name: 'disabled-skill', description: 'Hidden', enabled: false },
|
||
],
|
||
},
|
||
],
|
||
archived: [],
|
||
})
|
||
const wrapper = mountForSession('session-skills', { profile: 'work' })
|
||
const textarea = wrapper.get('textarea')
|
||
|
||
await textarea.setValue('/skill')
|
||
await nextTick()
|
||
|
||
await wrapper.get('.slash-command-item').trigger('mousedown')
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
expect(fetchSkillsMock).toHaveBeenCalledWith('work')
|
||
expect(wrapper.text()).toContain('/skill github-pr-review')
|
||
expect(wrapper.text()).toContain('Review pull requests')
|
||
expect(wrapper.text()).not.toContain('disabled-skill')
|
||
|
||
await wrapper.get('.skill-picker-item').trigger('click')
|
||
await nextTick()
|
||
|
||
expect((textarea.element as HTMLTextAreaElement).value).toBe('/skill github-pr-review ')
|
||
})
|
||
|
||
it('hides bridge autocomplete for non-Hermes slash prefixes', async () => {
|
||
const wrapper = mountForSession('session-prefixes')
|
||
const textarea = wrapper.get('textarea')
|
||
|
||
await textarea.setValue('/')
|
||
await nextTick()
|
||
expect(wrapper.findAll('.slash-command-item').length).toBeGreaterThan(0)
|
||
|
||
await textarea.setValue('/ter')
|
||
await nextTick()
|
||
|
||
expect(wrapper.find('.slash-command-dropdown').exists()).toBe(false)
|
||
})
|
||
})
|