feat: add session search modal (#128)
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
// @vitest-environment jsdom
|
||||
import { nextTick, defineComponent, h } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
fetchSessionsMock: vi.fn(),
|
||||
searchSessionsMock: vi.fn(),
|
||||
routerPushMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
fetchSessions: apiMocks.fetchSessionsMock,
|
||||
searchSessions: apiMocks.searchSessionsMock,
|
||||
}))
|
||||
|
||||
const chatStoreMock = vi.hoisted(() => ({
|
||||
sessions: [] as Array<Record<string, any>>,
|
||||
loadSessions: vi.fn(),
|
||||
switchSession: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/chat', () => ({
|
||||
useChatStore: () => chatStoreMock,
|
||||
}))
|
||||
|
||||
const routerCurrentRoute = { value: { name: 'hermes.logs' } }
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
currentRoute: routerCurrentRoute,
|
||||
push: apiMocks.routerPushMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
...actual,
|
||||
useMessage: () => ({
|
||||
error: vi.fn(),
|
||||
}),
|
||||
NModal: {
|
||||
props: ['show'],
|
||||
emits: ['update:show'],
|
||||
template: '<div v-if="show" class="n-modal-stub"><slot /></div>',
|
||||
},
|
||||
NInput: {
|
||||
props: ['value', 'size'],
|
||||
emits: ['update:value', 'keydown'],
|
||||
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" />',
|
||||
},
|
||||
NSpin: {
|
||||
template: '<div class="n-spin-stub"><slot /></div>',
|
||||
},
|
||||
NButton: {
|
||||
template: '<button class="n-button-stub"><slot /></button>',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
|
||||
import { useSessionSearch } from '@/composables/useSessionSearch'
|
||||
import { useKeyboard } from '@/composables/useKeyboard'
|
||||
|
||||
function flushPromises() {
|
||||
return Promise.resolve().then(() => Promise.resolve())
|
||||
}
|
||||
|
||||
describe('session search modal', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
chatStoreMock.sessions = []
|
||||
chatStoreMock.loadSessions.mockResolvedValue(undefined)
|
||||
chatStoreMock.switchSession.mockResolvedValue(undefined)
|
||||
apiMocks.fetchSessionsMock.mockResolvedValue([
|
||||
{
|
||||
id: 'recent-1',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Recent Docker fix',
|
||||
preview: 'recent preview',
|
||||
started_at: 1710000000,
|
||||
ended_at: 1710000001,
|
||||
last_active: 1710000002,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openrouter',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
},
|
||||
])
|
||||
apiMocks.searchSessionsMock.mockResolvedValue([
|
||||
{
|
||||
id: 'match-1',
|
||||
source: 'telegram',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Debugging session',
|
||||
preview: 'search preview',
|
||||
started_at: 1710001000,
|
||||
ended_at: null,
|
||||
last_active: 1710001005,
|
||||
message_count: 4,
|
||||
tool_call_count: 1,
|
||||
input_tokens: 3,
|
||||
output_tokens: 4,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openrouter',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
matched_message_id: 17,
|
||||
snippet: 'docker compose up',
|
||||
rank: 0.1,
|
||||
},
|
||||
])
|
||||
routerCurrentRoute.value = { name: 'hermes.logs' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens from Cmd/Ctrl+K and loads recent sessions', async () => {
|
||||
const { openSessionSearch, sessionSearchOpen } = useSessionSearch()
|
||||
const wrapper = mount(SessionSearchModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
NModal: false,
|
||||
NInput: false,
|
||||
NSpin: false,
|
||||
NButton: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
openSessionSearch()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(sessionSearchOpen.value).toBe(true)
|
||||
expect(apiMocks.fetchSessionsMock).toHaveBeenCalledWith(undefined, 8)
|
||||
expect(wrapper.text()).toContain('Recent Docker fix')
|
||||
})
|
||||
|
||||
it('searches by content and opens the matched session', async () => {
|
||||
const { openSessionSearch } = useSessionSearch()
|
||||
const wrapper = mount(SessionSearchModal)
|
||||
|
||||
openSessionSearch()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input.n-input-stub')
|
||||
await input.setValue('docker')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(apiMocks.searchSessionsMock).toHaveBeenCalledWith('docker', undefined, 10)
|
||||
expect(wrapper.text()).toContain('Debugging session')
|
||||
|
||||
await wrapper.find('button.result-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(chatStoreMock.loadSessions).toHaveBeenCalled()
|
||||
expect(chatStoreMock.switchSession).toHaveBeenCalledWith('match-1', '17')
|
||||
expect(apiMocks.routerPushMock).toHaveBeenCalledWith({ name: 'hermes.chat' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyboard shortcut', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const { closeSessionSearch } = useSessionSearch()
|
||||
closeSessionSearch()
|
||||
chatStoreMock.newChat.mockReset()
|
||||
})
|
||||
|
||||
it('opens session search on Cmd/Ctrl+K', async () => {
|
||||
const Dummy = defineComponent({
|
||||
setup() {
|
||||
useKeyboard()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
mount(Dummy)
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(useSessionSearch().sessionSearchOpen.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const openSessionSearchMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/useSessionSearch', () => ({
|
||||
useSessionSearch: () => ({
|
||||
openSessionSearch: openSessionSearchMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/app', () => ({
|
||||
useAppStore: () => ({
|
||||
sidebarOpen: true,
|
||||
connected: true,
|
||||
serverVersion: 'test',
|
||||
updateAvailable: false,
|
||||
updating: false,
|
||||
toggleSidebar: vi.fn(),
|
||||
closeSidebar: vi.fn(),
|
||||
doUpdate: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>()
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({ name: 'hermes.chat' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTheme', () => ({
|
||||
useTheme: () => ({ isDark: false }),
|
||||
}))
|
||||
|
||||
vi.mock('/logo.png', () => ({
|
||||
default: 'logo.png',
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
...actual,
|
||||
useMessage: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
NButton: {
|
||||
template: '<button><slot /></button>',
|
||||
},
|
||||
NSelect: {
|
||||
template: '<div />',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
|
||||
describe('AppSidebar search entry', () => {
|
||||
beforeEach(() => {
|
||||
openSessionSearchMock.mockClear()
|
||||
})
|
||||
|
||||
it('opens the session search modal from the sidebar button', async () => {
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileSelector: true,
|
||||
ModelSelector: true,
|
||||
LanguageSwitch: true,
|
||||
ThemeSwitch: true,
|
||||
NButton: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const searchButton = buttons.find(node => node.text().includes('sidebar.search'))
|
||||
expect(searchButton).toBeTruthy()
|
||||
|
||||
await searchButton!.trigger('click')
|
||||
expect(openSessionSearchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user