178 lines
5.2 KiB
TypeScript
178 lines
5.2 KiB
TypeScript
|
|
// @vitest-environment jsdom
|
||
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
|
import { mount } from '@vue/test-utils'
|
||
|
|
import { createPinia, setActivePinia } from 'pinia'
|
||
|
|
|
||
|
|
const mockConversationsApi = vi.hoisted(() => ({
|
||
|
|
fetchConversationSummaries: vi.fn(),
|
||
|
|
fetchConversationDetail: vi.fn(),
|
||
|
|
}))
|
||
|
|
|
||
|
|
vi.mock('@/api/hermes/conversations', () => mockConversationsApi)
|
||
|
|
|
||
|
|
vi.mock('vue-i18n', () => ({
|
||
|
|
useI18n: () => ({
|
||
|
|
t: (key: string, params?: Record<string, unknown>) => {
|
||
|
|
if (key === 'chat.linkedSessions' && params?.count != null) return `${params.count} linked`
|
||
|
|
return key
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
}))
|
||
|
|
|
||
|
|
import ConversationMonitorPane from '@/components/hermes/chat/ConversationMonitorPane.vue'
|
||
|
|
|
||
|
|
async function flushPromises() {
|
||
|
|
await Promise.resolve()
|
||
|
|
await Promise.resolve()
|
||
|
|
await Promise.resolve()
|
||
|
|
await Promise.resolve()
|
||
|
|
}
|
||
|
|
|
||
|
|
function deferred<T>() {
|
||
|
|
let resolve!: (value: T) => void
|
||
|
|
const promise = new Promise<T>(res => {
|
||
|
|
resolve = res
|
||
|
|
})
|
||
|
|
return { promise, resolve }
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('ConversationMonitorPane', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks()
|
||
|
|
setActivePinia(createPinia())
|
||
|
|
vi.useFakeTimers()
|
||
|
|
mockConversationsApi.fetchConversationSummaries.mockResolvedValue([
|
||
|
|
{
|
||
|
|
id: 'conv-1',
|
||
|
|
title: 'First conversation',
|
||
|
|
source: 'cli',
|
||
|
|
model: 'openai/gpt-5.4',
|
||
|
|
started_at: 10,
|
||
|
|
ended_at: 20,
|
||
|
|
last_active: 20,
|
||
|
|
message_count: 2,
|
||
|
|
tool_call_count: 0,
|
||
|
|
input_tokens: 3,
|
||
|
|
output_tokens: 5,
|
||
|
|
cache_read_tokens: 0,
|
||
|
|
cache_write_tokens: 0,
|
||
|
|
reasoning_tokens: 0,
|
||
|
|
billing_provider: 'openai',
|
||
|
|
estimated_cost_usd: 0,
|
||
|
|
actual_cost_usd: 0,
|
||
|
|
cost_status: 'estimated',
|
||
|
|
preview: 'preview',
|
||
|
|
is_active: true,
|
||
|
|
thread_session_count: 1,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'conv-2',
|
||
|
|
title: 'Second conversation',
|
||
|
|
source: 'discord',
|
||
|
|
model: 'openai/gpt-5.4',
|
||
|
|
started_at: 30,
|
||
|
|
ended_at: 40,
|
||
|
|
last_active: 40,
|
||
|
|
message_count: 2,
|
||
|
|
tool_call_count: 0,
|
||
|
|
input_tokens: 3,
|
||
|
|
output_tokens: 5,
|
||
|
|
cache_read_tokens: 0,
|
||
|
|
cache_write_tokens: 0,
|
||
|
|
reasoning_tokens: 0,
|
||
|
|
billing_provider: 'openai',
|
||
|
|
estimated_cost_usd: 0,
|
||
|
|
actual_cost_usd: 0,
|
||
|
|
cost_status: 'estimated',
|
||
|
|
preview: 'preview-2',
|
||
|
|
is_active: false,
|
||
|
|
thread_session_count: 2,
|
||
|
|
},
|
||
|
|
])
|
||
|
|
mockConversationsApi.fetchConversationDetail.mockResolvedValue({
|
||
|
|
session_id: 'conv-1',
|
||
|
|
visible_count: 2,
|
||
|
|
thread_session_count: 1,
|
||
|
|
messages: [
|
||
|
|
{ id: 1, session_id: 'conv-1', role: 'user', content: 'hello', timestamp: 11 },
|
||
|
|
{ id: 2, session_id: 'conv-1', role: 'assistant', content: 'world', timestamp: 12 },
|
||
|
|
],
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
vi.useRealTimers()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('loads conversations and the first transcript using the humanOnly preference', async () => {
|
||
|
|
const wrapper = mount(ConversationMonitorPane, {
|
||
|
|
props: { humanOnly: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
await flushPromises()
|
||
|
|
|
||
|
|
expect(mockConversationsApi.fetchConversationSummaries).toHaveBeenCalledWith({ humanOnly: true })
|
||
|
|
expect(mockConversationsApi.fetchConversationDetail).toHaveBeenCalledWith('conv-1', { humanOnly: true })
|
||
|
|
expect(wrapper.text()).toContain('First conversation')
|
||
|
|
expect(wrapper.text()).toContain('hello')
|
||
|
|
expect(wrapper.text()).toContain('world')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('ignores stale detail responses when selection changes quickly', async () => {
|
||
|
|
const first = deferred<any>()
|
||
|
|
const second = deferred<any>()
|
||
|
|
mockConversationsApi.fetchConversationDetail
|
||
|
|
.mockReturnValueOnce(first.promise)
|
||
|
|
.mockReturnValueOnce(second.promise)
|
||
|
|
|
||
|
|
const wrapper = mount(ConversationMonitorPane, {
|
||
|
|
props: { humanOnly: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
await flushPromises()
|
||
|
|
expect(mockConversationsApi.fetchConversationDetail).toHaveBeenCalledWith('conv-1', { humanOnly: true })
|
||
|
|
|
||
|
|
const sessionButtons = wrapper.findAll('.conversation-monitor__session')
|
||
|
|
expect(sessionButtons).toHaveLength(2)
|
||
|
|
await sessionButtons[1].trigger('click')
|
||
|
|
|
||
|
|
expect(mockConversationsApi.fetchConversationDetail).toHaveBeenLastCalledWith('conv-2', { humanOnly: true })
|
||
|
|
|
||
|
|
second.resolve({
|
||
|
|
session_id: 'conv-2',
|
||
|
|
visible_count: 1,
|
||
|
|
thread_session_count: 2,
|
||
|
|
messages: [
|
||
|
|
{ id: 21, session_id: 'conv-2', role: 'assistant', content: 'newer detail wins', timestamp: 41 },
|
||
|
|
],
|
||
|
|
})
|
||
|
|
await flushPromises()
|
||
|
|
|
||
|
|
first.resolve({
|
||
|
|
session_id: 'conv-1',
|
||
|
|
visible_count: 1,
|
||
|
|
thread_session_count: 1,
|
||
|
|
messages: [
|
||
|
|
{ id: 11, session_id: 'conv-1', role: 'assistant', content: 'stale detail loses', timestamp: 12 },
|
||
|
|
],
|
||
|
|
})
|
||
|
|
await flushPromises()
|
||
|
|
|
||
|
|
const renderedMessages = wrapper.findAll('.conversation-monitor__message-content').map(node => node.text())
|
||
|
|
expect(renderedMessages).toEqual(['newer detail wins'])
|
||
|
|
})
|
||
|
|
|
||
|
|
it('clears the polling interval on unmount', async () => {
|
||
|
|
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
|
||
|
|
|
||
|
|
const wrapper = mount(ConversationMonitorPane, {
|
||
|
|
props: { humanOnly: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
await flushPromises()
|
||
|
|
wrapper.unmount()
|
||
|
|
|
||
|
|
expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
|
||
|
|
})
|
||
|
|
})
|