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.
245 lines
8.5 KiB
TypeScript
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)
|
|
})
|
|
})
|