The Phase 006 walkthrough surfaced four issues the automated checks could not see. Dead naive-ui class names left behind by the migration stopped matching, tooltips that carried their label as trailing default-slot text leaked that text into the flex row, the workspace panel squeezed the chat column until the input toolbar wrapped, and the Escape fallback still looked for naive-ui's modal mask. Tooltips now pass their label through the content prop, the tool panel reserves a minimum width for the chat column, and Escape finds the visible Arco modal and clicks its close button.
247 lines
7.0 KiB
TypeScript
247 lines
7.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { nextTick, defineComponent, h } from 'vue'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { config, 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,
|
|
}),
|
|
}))
|
|
|
|
// The search modal teleports to body in jsdom, so stub the overlays to render
|
|
// inline and keep the DOM shape the assertions below rely on.
|
|
config.global.stubs = {
|
|
AModal: {
|
|
name: 'AModal',
|
|
props: ['visible'],
|
|
emits: ['update:visible'],
|
|
template: '<div v-if="visible" class="a-modal-stub"><slot /></div>',
|
|
},
|
|
AInput: {
|
|
name: 'AInput',
|
|
// `size` is an Arco prop, not an HTML input attribute; declaring it keeps
|
|
// it off the native element.
|
|
props: ['modelValue', 'size'],
|
|
emits: ['update:modelValue'],
|
|
template: '<input class="a-input-stub" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
},
|
|
ASpin: {
|
|
name: 'ASpin',
|
|
template: '<div class="a-spin-stub"><slot /></div>',
|
|
},
|
|
AButton: {
|
|
name: 'AButton',
|
|
template: '<button class="a-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.a-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.session', params: { sessionId: 'match-1' } })
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
// The chat page dialogs all moved to Arco in Phase 006, so the Esc fallback
|
|
// must look for Arco's close button, not naive-ui's.
|
|
it('closes an open Arco modal on Escape, and ignores naive-ui markup', () => {
|
|
const close = vi.fn()
|
|
const modal = document.createElement('div')
|
|
modal.className = 'arco-modal'
|
|
const closeBtn = document.createElement('div')
|
|
closeBtn.className = 'arco-modal-close-btn'
|
|
closeBtn.addEventListener('click', close)
|
|
modal.appendChild(closeBtn)
|
|
document.body.appendChild(modal)
|
|
|
|
const legacyModal = document.createElement('div')
|
|
legacyModal.className = 'n-modal n-modal-mask'
|
|
document.body.appendChild(legacyModal)
|
|
|
|
const Dummy = defineComponent({
|
|
setup() {
|
|
useKeyboard()
|
|
return () => h('div')
|
|
},
|
|
})
|
|
const wrapper = mount(Dummy)
|
|
// jsdom reports offsetParent as null, so make the modal look visible.
|
|
Object.defineProperty(modal, 'offsetParent', { value: document.body })
|
|
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
expect(close).toHaveBeenCalled()
|
|
|
|
wrapper.unmount()
|
|
modal.remove()
|
|
legacyModal.remove()
|
|
})
|
|
})
|