Files
hermes-ui/tests/client/chat-input-draft.test.ts
toor effc29085c 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.
2026-10-17 22:17:32 +08:00

283 lines
9.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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)
})
})