// @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( '@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: '
', } /** * 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: `
`, } const ADoptionStub = { name: 'Doption', props: ['value'], template: '', } 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['sessions'][number]> = {}, displayOverrides: Record = {}, ) { 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) }) })