Files
hermes-desktop/tests/client/chat-store-session-loading.test.ts
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

163 lines
6.4 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const chatApi = vi.hoisted(() => ({
startRunViaSocket: vi.fn(),
resumeSession: vi.fn(),
registerSessionHandlers: vi.fn(),
unregisterSessionHandlers: vi.fn(),
}))
vi.mock('@/api/hermes/chat', () => ({
startRunViaSocket: chatApi.startRunViaSocket,
resumeSession: chatApi.resumeSession,
registerSessionHandlers: chatApi.registerSessionHandlers,
unregisterSessionHandlers: chatApi.unregisterSessionHandlers,
getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })),
respondToolApproval: vi.fn(),
respondClarify: vi.fn(),
onPeerUserMessage: vi.fn(() => vi.fn()),
onSessionCommand: vi.fn(() => vi.fn()),
onSessionTitleUpdated: vi.fn(() => vi.fn()),
onSessionWorkspaceUpdated: vi.fn(() => vi.fn()),
}))
vi.mock('@/api/client', () => ({
getActiveProfileName: () => 'default',
hasApiKey: () => false,
}))
vi.mock('@/api/hermes/sessions', () => ({
archiveSession: vi.fn(),
deleteSession: vi.fn(),
fetchSessionMessagesPage: vi.fn(),
fetchSessions: vi.fn(),
fetchWorkspaceRunChangesForSession: vi.fn(async () => []),
fetchWorkspaceRunChangeFile: vi.fn(async () => null),
setSessionModel: vi.fn(),
}))
vi.mock('@/api/hermes/download', () => ({
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
}))
vi.mock('@/api/hermes/system', () => ({
checkHealth: vi.fn(),
fetchAvailableModels: vi.fn(),
addCustomModel: vi.fn(),
removeCustomModel: vi.fn(),
updateDefaultModel: vi.fn(),
updateModelVisibility: vi.fn(),
updateModelAlias: vi.fn(),
}))
vi.mock('@/utils/completion-sound', () => ({
primeCompletionSound: vi.fn(),
playCompletionSound: vi.fn(),
}))
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
function makeSession(id: string): Session {
return {
id,
title: id,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
import { fetchSessionMessagesPage } from '@/api/hermes/sessions'
describe('session loading recovery', () => {
let callbacks: Array<(data: any) => void>
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
setActivePinia(createPinia())
callbacks = []
chatApi.resumeSession.mockImplementation((_id: string, callback: any) => { callbacks.push(callback) })
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() })
it('ignores old replies and keeps loading the latest request during A/B/A switching', async () => {
const store = useChatStore()
store.sessions = [makeSession('a'), makeSession('b')]
const first = store.switchSession('a')
const second = store.switchSession('b')
const third = store.switchSession('a')
callbacks[0]({ session_id: 'a', messages: [{ role: 'user', content: 'old' }], events: [], isWorking: false })
await Promise.all([first, second])
expect(store.isLoadingMessages).toBe(true)
expect(store.messages).toEqual([])
callbacks[2]({ session_id: 'a', messages: [{ role: 'user', content: 'latest' }], events: [], isWorking: false })
await third
expect(store.messages[0]?.content).toBe('latest')
expect(store.isLoadingMessages).toBe(false)
})
it('loads history via HTTP when the socket does not reply', async () => {
const store = useChatStore()
store.sessions = [{ ...makeSession('a'), profile: 'research' }]
vi.mocked(fetchSessionMessagesPage).mockResolvedValue({ session: { title: 'Recovered' }, messages: [{ id: 1, role: 'user', content: 'HTTP history' }], total: 1, hasMore: false } as any)
const pending = store.switchSession('a')
await vi.advanceTimersByTimeAsync(3000)
await pending
expect(fetchSessionMessagesPage).toHaveBeenCalledWith('a', 0, expect.any(Number), 'research')
expect(store.messages[0]?.content).toBe('HTTP history')
expect(store.messageLoadFailed).toBe(false)
expect(store.isStreaming).toBe(false)
callbacks[0]({ session_id: 'a', messages: [{ role: 'user', content: 'stale socket' }], isWorking: true, events: [] })
expect(store.messages[0]?.content).toBe('HTTP history')
expect(store.isStreaming).toBe(true)
})
it('falls back immediately if the socket cannot be opened', async () => {
const store = useChatStore()
store.sessions = [makeSession('a')]
chatApi.resumeSession.mockImplementation(() => { throw new Error('socket unavailable') })
vi.mocked(fetchSessionMessagesPage).mockResolvedValue({ session: {}, messages: [{ role: 'user', content: 'HTTP fallback' }], total: 1, hasMore: false } as any)
await store.switchSession('a')
expect(store.messages[0]?.content).toBe('HTTP fallback')
expect(store.messageLoadFailed).toBe(false)
await vi.advanceTimersByTimeAsync(3000)
expect(fetchSessionMessagesPage).toHaveBeenCalledTimes(1)
})
it('exposes failure and supports retrying the same session', async () => {
const store = useChatStore()
store.sessions = [makeSession('a')]
vi.mocked(fetchSessionMessagesPage).mockRejectedValue(new Error('offline'))
const pending = store.switchSession('a')
await vi.advanceTimersByTimeAsync(3000)
await pending
expect(store.messageLoadFailed).toBe(true)
expect(store.isLoadingMessages).toBe(false)
const retry = store.switchSession('a')
expect(store.messageLoadFailed).toBe(false)
callbacks.at(-1)!({ session_id: 'a', messages: [{ role: 'user', content: 'Recovered' }], events: [], isWorking: false })
await retry
expect(store.messages[0]?.content).toBe('Recovered')
})
it('does not apply a late HTTP fallback after another selection', async () => {
const store = useChatStore()
store.sessions = [makeSession('a'), makeSession('b')]
let finish!: (data: any) => void
vi.mocked(fetchSessionMessagesPage).mockReturnValue(new Promise(resolve => { finish = resolve }))
const first = store.switchSession('a')
await vi.advanceTimersByTimeAsync(3000)
const second = store.switchSession('b')
callbacks[1]({ session_id: 'b', messages: [{ role: 'user', content: 'B' }], events: [], isWorking: false })
await second
finish({ session: {}, messages: [{ role: 'user', content: 'A' }], total: 1, hasMore: false })
await first
expect(store.activeSessionId).toBe('b')
expect(store.messages[0]?.content).toBe('B')
expect(store.messageLoadFailed).toBe(false)
})
})