feat(web-ui): add pinned sessions and live monitor in Chat (#118)

* feat: add single-page live session monitor and chat pinning

* fix: restore full test green after main merge

* fix: use Array.from instead of Set spread for ts-node compatibility

[...new Set()] requires downlevelIteration which isn't enabled in
ts-node dev mode, causing sonic-boom crash on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ekko <fqsy1416@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zhicheng Han
2026-04-22 02:09:58 +02:00
committed by GitHub
parent 83ad9642e2
commit 3f88553765
34 changed files with 2497 additions and 278 deletions
+236
View File
@@ -0,0 +1,236 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const mockChatStore = vi.hoisted(() => ({
sessions: [] as Array<Record<string, any>>,
activeSessionId: null as string | null,
activeSession: null as Record<string, any> | null,
isLoadingSessions: false,
sessionsLoaded: true,
isSessionLive: vi.fn((sessionId: string) => sessionId === 'discord-active'),
newChat: vi.fn(),
switchSession: vi.fn(),
deleteSession: vi.fn(),
}))
vi.mock('@/stores/hermes/chat', () => ({
useChatStore: () => mockChatStore,
}))
vi.mock('@/api/hermes/sessions', () => ({
renameSession: vi.fn(),
}))
vi.mock('@/components/hermes/chat/MessageList.vue', () => ({
default: {
template: '<div class="message-list-mock" />',
},
}))
vi.mock('@/components/hermes/chat/ChatInput.vue', () => ({
default: {
template: '<div class="chat-input-mock" />',
},
}))
vi.mock('@/components/hermes/chat/ConversationMonitorPane.vue', () => ({
default: {
props: ['humanOnly'],
template: '<div class="conversation-monitor-mock">monitor {{ humanOnly }}</div>',
},
}))
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: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}
})
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
function makeSession(id: string, overrides: Record<string, any> = {}) {
return {
id,
title: id,
source: 'api_server',
messages: [],
createdAt: 1,
updatedAt: 1,
model: 'gpt-4o',
...overrides,
}
}
const NButtonStub = {
emits: ['click'],
template: '<button class="n-button-stub" v-bind="$attrs" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
}
const NDropdownStub = {
props: ['options', 'show'],
emits: ['select', 'clickoutside'],
template: `
<div v-if="show" class="dropdown-stub">
<button
v-for="option in options"
:key="option.key"
class="dropdown-option"
@click="$emit('select', option.key)"
>{{ option.label }}</button>
</div>
`,
}
describe('ChatPanel modes and pinning', () => {
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
const profilesStore = useProfilesStore()
profilesStore.activeProfileName = 'default'
vi.clearAllMocks()
const activeDiscord = makeSession('discord-active', {
title: 'Discord Active',
source: 'discord',
createdAt: 100,
updatedAt: 500,
})
const olderDiscord = makeSession('discord-older', {
title: 'Discord Older',
source: 'discord',
createdAt: 200,
updatedAt: 400,
})
const slackSession = makeSession('slack-1', {
title: 'Slack Selected',
source: 'slack',
createdAt: 50,
updatedAt: 50,
})
const apiSession = makeSession('api-1', {
title: 'API Session',
source: 'api_server',
createdAt: 300,
updatedAt: 300,
})
mockChatStore.sessions = [apiSession, slackSession, olderDiscord, activeDiscord]
mockChatStore.activeSessionId = apiSession.id
mockChatStore.activeSession = apiSession
mockChatStore.isLoadingSessions = false
mockChatStore.sessionsLoaded = true
mockChatStore.isSessionLive.mockImplementation((sessionId: string) => sessionId === activeDiscord.id)
mockChatStore.switchSession.mockImplementation((sessionId: string) => {
mockChatStore.activeSessionId = sessionId
mockChatStore.activeSession = mockChatStore.sessions.find(s => s.id === sessionId) ?? null
})
})
it('pins and unpins a session through the context menu without duplicating it', async () => {
const prefsStore = useSessionBrowserPrefsStore()
const wrapper = mount(ChatPanel, {
global: {
stubs: {
NButton: NButtonStub,
NDropdown: NDropdownStub,
NInput: true,
NModal: true,
NPopconfirm: true,
NTooltip: true,
},
},
})
const slackRow = wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))
expect(slackRow).toBeTruthy()
await slackRow!.trigger('contextmenu')
;(wrapper.vm as any).handleContextMenuSelect('pin')
await Promise.resolve()
expect(prefsStore.pinnedIds).toEqual(['slack-1'])
const groupLabelsAfterPin = wrapper.findAll('.session-group-label').map(node => node.text())
expect(groupLabelsAfterPin[0]).toBe('chat.pinned')
expect(wrapper.findAll('.session-item-title').map(node => node.text()).filter(text => text === 'Slack Selected')).toHaveLength(1)
const pinnedRow = wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))
await pinnedRow!.trigger('contextmenu')
;(wrapper.vm as any).handleContextMenuSelect('pin')
await Promise.resolve()
expect(prefsStore.pinnedIds).toEqual([])
expect(wrapper.findAll('.session-group-label').map(node => node.text())).not.toContain('chat.pinned')
expect(wrapper.findAll('.session-item-title').map(node => node.text()).filter(text => text === 'Slack Selected')).toHaveLength(1)
})
it('does not prune saved pins before sessions have completed loading or when the list is empty', () => {
const prefsStore = useSessionBrowserPrefsStore()
const pruneSpy = vi.spyOn(prefsStore, 'pruneMissingSessions')
mockChatStore.sessions = []
mockChatStore.activeSessionId = null
mockChatStore.activeSession = null
mockChatStore.sessionsLoaded = false
mount(ChatPanel, {
global: {
stubs: {
NButton: NButtonStub,
NDropdown: NDropdownStub,
NInput: true,
NModal: true,
NPopconfirm: true,
NTooltip: true,
},
},
})
expect(pruneSpy).not.toHaveBeenCalled()
})
it('switches between live and chat mode with accessible pressed state and restores sidebar visibility', async () => {
const wrapper = mount(ChatPanel, {
global: {
stubs: {
NDropdown: NDropdownStub,
NInput: true,
NModal: true,
NPopconfirm: true,
NTooltip: true,
NButton: NButtonStub,
},
},
})
const modeButtons = wrapper.findAll('.chat-mode-toggle button')
expect(modeButtons[0].attributes('aria-pressed')).toBe('true')
expect(modeButtons[1].attributes('aria-pressed')).toBe('false')
expect(wrapper.find('.session-list').classes()).not.toContain('collapsed')
await modeButtons[1].trigger('click')
const liveButtons = wrapper.findAll('.chat-mode-toggle button')
expect(liveButtons[0].attributes('aria-pressed')).toBe('false')
expect(liveButtons[1].attributes('aria-pressed')).toBe('true')
expect(wrapper.find('.conversation-monitor-mock').exists()).toBe(true)
await liveButtons[0].trigger('click')
const chatButtons = wrapper.findAll('.chat-mode-toggle button')
expect(chatButtons[0].attributes('aria-pressed')).toBe('true')
expect(chatButtons[1].attributes('aria-pressed')).toBe('false')
expect(wrapper.find('.session-list').classes()).not.toContain('collapsed')
expect(wrapper.find('.chat-input-mock').exists()).toBe(true)
})
})
+21 -5
View File
@@ -13,10 +13,23 @@ const mockChatStore = vi.hoisted(() => ({
deleteSession: vi.fn(),
}))
const mockPrefsStore = vi.hoisted(() => ({
pinnedIds: [] as string[],
humanOnly: true,
isPinned: vi.fn(() => false),
togglePinned: vi.fn(),
setHumanOnly: vi.fn(),
pruneMissingSessions: vi.fn(),
}))
vi.mock('@/stores/hermes/chat', () => ({
useChatStore: () => mockChatStore,
}))
vi.mock('@/stores/hermes/session-browser-prefs', () => ({
useSessionBrowserPrefsStore: () => mockPrefsStore,
}))
vi.mock('@/api/hermes/sessions', () => ({
renameSession: vi.fn(),
}))
@@ -33,6 +46,12 @@ vi.mock('@/components/hermes/chat/ChatInput.vue', () => ({
},
}))
vi.mock('@/components/hermes/chat/ConversationMonitorPane.vue', () => ({
default: {
template: '<div class="conversation-monitor-mock" />',
},
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
@@ -128,8 +147,8 @@ describe('ChatPanel session list', () => {
const sessionTitles = wrapper.findAll('.session-item-title').map(node => node.text())
expect(sessionTitles.slice(0, 2)).toEqual(['Discord Active', 'Discord Older'])
const activeIndicator = wrapper.find('.session-item.active .session-item-active-indicator')
expect(activeIndicator.exists()).toBe(true)
const liveRow = wrapper.findAll('.session-item').find(node => node.text().includes('Discord Active'))
expect(liveRow?.find('.session-item-active-indicator').exists()).toBe(true)
await wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))!.trigger('click')
@@ -137,8 +156,5 @@ describe('ChatPanel session list', () => {
const groupLabelsAfterClick = wrapper.findAll('.session-group-label').map(node => node.text())
expect(groupLabelsAfterClick[0]).toBe('Discord')
const activeTitlesAfterClick = wrapper.findAll('.session-item.active .session-item-title').map(node => node.text())
expect(activeTitlesAfterClick).toEqual(['Discord Active'])
})
})
+17 -10
View File
@@ -53,6 +53,12 @@ async function flushPromises() {
await Promise.resolve()
}
const PROFILE = 'default'
const ACTIVE_SESSION_KEY = `hermes_active_session_${PROFILE}`
const SESSIONS_CACHE_KEY = `hermes_sessions_cache_v1_${PROFILE}`
const sessionMessagesKey = (sessionId: string) => `hermes_session_msgs_v1_${PROFILE}_${sessionId}_`
const inFlightKey = (sessionId: string) => `hermes_in_flight_v1_${PROFILE}_${sessionId}`
describe('Chat Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -82,19 +88,20 @@ describe('Chat Store', () => {
{ id: 'm1', role: 'user', content: 'draft', timestamp: 1 },
]
window.localStorage.setItem('hermes_active_session', 'local-1')
window.localStorage.setItem('hermes_sessions_cache_v1', JSON.stringify([cachedSession]))
window.localStorage.setItem('hermes_session_msgs_v1_local-1', JSON.stringify(cachedMessages))
window.localStorage.setItem(ACTIVE_SESSION_KEY, 'local-1')
window.localStorage.setItem(SESSIONS_CACHE_KEY, JSON.stringify([cachedSession]))
window.localStorage.setItem(sessionMessagesKey('local-1'), JSON.stringify(cachedMessages))
mockSessionsApi.fetchSessions.mockResolvedValue([makeSummary('remote-1', 'Remote Session')])
mockSessionsApi.fetchSession.mockResolvedValue(null)
const store = useChatStore()
const loadPromise = store.loadSessions()
expect(store.activeSessionId).toBe('local-1')
expect(store.messages.map(m => m.content)).toEqual(['draft'])
await flushPromises()
await loadPromise
expect(store.sessions.map(s => s.id)).toEqual(['local-1', 'remote-1'])
expect(store.activeSession?.id).toBe('local-1')
@@ -109,10 +116,10 @@ describe('Chat Store', () => {
const sid = store.activeSessionId
expect(sid).toBeTruthy()
expect(window.localStorage.getItem('hermes_active_session')).toBe(sid)
expect(window.localStorage.getItem(ACTIVE_SESSION_KEY)).toBe(sid)
const cachedMessages = JSON.parse(
window.localStorage.getItem(`hermes_session_msgs_v1_${sid}`) || '[]',
window.localStorage.getItem(sessionMessagesKey(sid!)) || '[]',
)
expect(cachedMessages).toEqual(
expect.arrayContaining([
@@ -127,9 +134,9 @@ describe('Chat Store', () => {
it('silently refreshes from server on SSE error instead of appending a fake error bubble', async () => {
vi.useFakeTimers()
window.localStorage.setItem('hermes_active_session', 'sess-1')
window.localStorage.setItem(ACTIVE_SESSION_KEY, 'sess-1')
window.localStorage.setItem(
'hermes_sessions_cache_v1',
SESSIONS_CACHE_KEY,
JSON.stringify([
{
id: 'sess-1',
@@ -142,7 +149,7 @@ describe('Chat Store', () => {
]),
)
window.localStorage.setItem(
'hermes_session_msgs_v1_sess-1',
sessionMessagesKey('sess-1'),
JSON.stringify([
{ id: 'old-user', role: 'user', content: 'old prompt', timestamp: 1 },
]),
@@ -221,6 +228,6 @@ describe('Chat Store', () => {
expect(store.messages.some(m => m.role === 'system' && m.content.includes('SSE connection error'))).toBe(false)
expect(store.messages.some(m => m.role === 'assistant' && m.content === 'final answer')).toBe(true)
expect(store.isRunActive).toBe(false)
expect(window.localStorage.getItem('hermes_in_flight_v1_sess-1')).toBeNull()
expect(window.localStorage.getItem(inFlightKey('sess-1'))).toBeNull()
})
})
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
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()
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)
})
})
+39
View File
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockRequest = vi.hoisted(() => vi.fn())
vi.mock('@/api/client', () => ({
request: mockRequest,
}))
import { fetchConversationDetail, fetchConversationSummaries } from '@/api/hermes/conversations'
describe('conversations api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('builds summaries URLs with optional params', async () => {
mockRequest.mockResolvedValue({ sessions: [] })
await fetchConversationSummaries()
await fetchConversationSummaries({ humanOnly: false, source: 'cli', limit: 25 })
expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/hermes/sessions/conversations')
expect(mockRequest).toHaveBeenNthCalledWith(2, '/api/hermes/sessions/conversations?humanOnly=false&source=cli&limit=25')
})
it('encodes detail URLs and forwards optional params', async () => {
mockRequest.mockResolvedValue({ session_id: 'conv', messages: [], visible_count: 0, thread_session_count: 1 })
await fetchConversationDetail('folder/with spaces', { humanOnly: false, source: 'discord' })
expect(mockRequest).toHaveBeenCalledWith('/api/hermes/sessions/conversations/folder%2Fwith%20spaces/messages?humanOnly=false&source=discord')
})
it('propagates conversation detail errors so the monitor can render an error state', async () => {
mockRequest.mockRejectedValue(new Error('boom'))
await expect(fetchConversationDetail('conv-1', { humanOnly: true })).rejects.toThrow('boom')
})
})
+9
View File
@@ -72,12 +72,21 @@ describe('Profiles Store', () => {
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
])
window.localStorage.setItem('hermes_sessions_cache_v1_test', '[]')
window.localStorage.setItem('hermes_session_msgs_v1_test_session-1', '[]')
window.localStorage.setItem('hermes_in_flight_v1_test_session-1', '{}')
window.localStorage.setItem('hermes_active_session_test', 'session-1')
window.localStorage.setItem('hermes_session_pins_v1_test', '[]')
window.localStorage.setItem('hermes_human_only_v1_test', 'false')
const store = useProfilesStore()
store.detailMap['test'] = { name: 'test', path: '/tmp/test', model: '', provider: '', gateway: '', skills: 0, hasEnv: false, hasSoulMd: false }
await store.deleteProfile('test')
expect(store.detailMap['test']).toBeUndefined()
expect(window.localStorage.getItem('hermes_session_pins_v1_test')).toBeNull()
expect(window.localStorage.getItem('hermes_human_only_v1_test')).toBeNull()
})
it('fetchProfileDetail uses cache', async () => {
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
describe('session browser prefs store', () => {
beforeEach(() => {
setActivePinia(createPinia())
window.localStorage.clear()
})
it('persists pins per profile and prunes missing sessions', () => {
const profilesStore = useProfilesStore()
profilesStore.activeProfileName = 'default'
const store = useSessionBrowserPrefsStore()
expect(store.pinnedIds).toEqual([])
store.togglePinned('session-1')
store.togglePinned('session-2')
expect(store.pinnedIds).toEqual(['session-1', 'session-2'])
expect(JSON.parse(window.localStorage.getItem('hermes_session_pins_v1_default') || '[]')).toEqual(['session-1', 'session-2'])
expect(store.pruneMissingSessions(['session-2'])).toBe(true)
expect(store.pinnedIds).toEqual(['session-2'])
expect(JSON.parse(window.localStorage.getItem('hermes_session_pins_v1_default') || '[]')).toEqual(['session-2'])
})
it('does not erase saved pins when the current session list is transiently empty', () => {
const profilesStore = useProfilesStore()
profilesStore.activeProfileName = 'default'
const store = useSessionBrowserPrefsStore()
store.togglePinned('session-1')
expect(store.pruneMissingSessions([])).toBe(false)
expect(store.pinnedIds).toEqual(['session-1'])
expect(JSON.parse(window.localStorage.getItem('hermes_session_pins_v1_default') || '[]')).toEqual(['session-1'])
})
it('reloads pin and human-only preferences automatically when the active profile changes', async () => {
const profilesStore = useProfilesStore()
profilesStore.activeProfileName = 'default'
const store = useSessionBrowserPrefsStore()
expect(store.humanOnly).toBe(true)
store.togglePinned('default-session')
store.setHumanOnly(false)
window.localStorage.setItem('hermes_session_pins_v1_work', JSON.stringify(['work-session']))
window.localStorage.setItem('hermes_human_only_v1_work', JSON.stringify(true))
profilesStore.activeProfileName = 'work'
await nextTick()
expect(store.profileName).toBe('work')
expect(store.pinnedIds).toEqual(['work-session'])
expect(store.humanOnly).toBe(true)
profilesStore.activeProfileName = 'default'
await nextTick()
expect(store.pinnedIds).toEqual(['default-session'])
expect(store.humanOnly).toBe(false)
})
})
+74
View File
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const mockSettingsStore = vi.hoisted(() => ({
sessionReset: { mode: 'both', idle_minutes: 60, at_hour: 0 },
saveSection: vi.fn(),
}))
const mockPrefsStore = vi.hoisted(() => ({
humanOnly: true,
setHumanOnly: vi.fn((value: boolean) => {
mockPrefsStore.humanOnly = value
}),
}))
vi.mock('@/stores/hermes/settings', () => ({
useSettingsStore: () => mockSettingsStore,
}))
vi.mock('@/stores/hermes/session-browser-prefs', () => ({
useSessionBrowserPrefsStore: () => mockPrefsStore,
}))
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: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}
})
import SessionSettings from '@/components/hermes/settings/SessionSettings.vue'
describe('SessionSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPrefsStore.humanOnly = true
})
it('surfaces the human-only preference in the Session tab', async () => {
const wrapper = mount(SessionSettings, {
global: {
stubs: {
SettingRow: {
props: ['label', 'hint'],
template: '<div class="setting-row"><div class="setting-row-label">{{ label }}</div><slot /></div>',
},
NSelect: true,
NInputNumber: true,
},
},
})
expect(wrapper.text()).toContain('settings.session.liveMonitorHumanOnly')
const toggle = wrapper.find('.n-switch')
expect(toggle.exists()).toBe(true)
await toggle.trigger('click')
await Promise.resolve()
expect(mockPrefsStore.setHumanOnly).toHaveBeenCalledWith(false)
})
})