Files
hermes-ui/tests/client/chat-input-draft.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

245 lines
8.5 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 { 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 }),
}))
vi.mock('naive-ui', () => ({
NButton: { template: '<button type="button" v-bind="$attrs"><slot /><slot name="icon" /></button>' },
NTooltip: { template: '<div><slot name="trigger" /><slot /></div>' },
NSwitch: { template: '<button type="button"></button>' },
NDropdown: { template: '<div><slot /></div>' },
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
NInputNumber: { template: '<input />' },
NPopselect: {
props: ['value', 'options'],
emits: ['update:value'],
template: `
<div class="n-popselect-stub">
<slot />
<button
v-for="option in options"
:key="option.value"
type="button"
class="n-popselect-option"
:data-value="option.value"
@click="$emit('update:value', option.value)"
>
{{ option.label }}
</button>
</div>
`,
},
useMessage: () => ({ error: vi.fn(), success: vi.fn() }),
}))
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] } })
}
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 coding-agent sessions', async () => {
const wrapper = mountForSession('session-codex', {
source: 'coding_agent',
agent: 'codex',
codingAgentId: 'codex',
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 reasoning effort selector for coding-agent sessions', async () => {
const wrapper = mountForSession('session-codex', {
source: 'coding_agent',
agent: 'codex',
codingAgentId: 'codex',
})
await nextTick()
expect(wrapper.find('.n-popselect-stub').exists()).toBe(true)
expect(wrapper.find('[data-value="high"]').exists()).toBe(true)
})
it('stores the selected reasoning effort for the active session', async () => {
const wrapper = mountForSession('session-reasoning')
const store = useChatStore()
await wrapper.get('[data-value="high"]').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)
})
})