feat: 灵犀 Studio Web UI 定制版
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-06-05 11:29:11 +08:00
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// vi.mock is hoisted, so mockReplace must be inside the factory
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { name: 'hermes.chat' } },
replace: vi.fn(),
},
}))
import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client'
import { getDownloadUrl } from '../../packages/client/src/api/hermes/download'
import { uploadFiles } from '../../packages/client/src/api/hermes/files'
import { batchDeleteSessions, importHermesSession } from '../../packages/client/src/api/hermes/sessions'
import router from '@/router'
function fakeJwt(payload: Record<string, unknown>) {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const body = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return `${header}.${body}.signature`
}
describe('API Client', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
describe('token management', () => {
it('hasApiKey returns false when no token', () => {
expect(hasApiKey()).toBe(false)
})
it('hasApiKey returns true after setApiKey', () => {
setApiKey('test-token')
expect(hasApiKey()).toBe(true)
})
it('getApiKey returns the stored token', () => {
setApiKey('my-token')
expect(getApiKey()).toBe('my-token')
})
it('clearApiKey removes the token', () => {
setApiKey('my-token')
clearApiKey()
expect(hasApiKey()).toBe(false)
expect(getApiKey()).toBe('')
})
it('reads the role from the stored JWT payload', () => {
setApiKey(fakeJwt({ sub: '1', role: 'super_admin' }))
expect(getStoredUserRole()).toBe('super_admin')
expect(isStoredSuperAdmin()).toBe(true)
setApiKey(fakeJwt({ sub: '2', role: 'admin' }))
expect(getStoredUserRole()).toBe('admin')
expect(isStoredSuperAdmin()).toBe(false)
})
})
describe('request', () => {
it('adds Authorization header when token exists', async () => {
setApiKey('secret-key')
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
await request('/api/hermes/sessions')
expect(mockFetch).toHaveBeenCalledOnce()
const [, options] = mockFetch.mock.calls[0]
expect(options.headers.Authorization).toBe('Bearer secret-key')
})
it('adds the active profile header, including default', async () => {
localStorage.setItem('hermes_active_profile_name', 'default')
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
await request('/api/hermes/sessions/session-1')
const [, options] = mockFetch.mock.calls[0]
expect(options.headers['X-Hermes-Profile']).toBe('default')
})
it('does not add the active profile header to profile-wide session collection requests', async () => {
localStorage.setItem('hermes_active_profile_name', 'research')
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
await request('/api/hermes/sessions')
const [, options] = mockFetch.mock.calls[0]
expect(options.headers['X-Hermes-Profile']).toBeUndefined()
})
it('does not add Authorization header when no token', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
await request('/api/hermes/sessions')
const [, options] = mockFetch.mock.calls[0]
expect(options.headers.Authorization).toBeUndefined()
})
it('clears token and redirects on 401 for local BFF endpoints', async () => {
setApiKey('secret-key')
mockFetch.mockResolvedValue({ ok: false, status: 401 })
await expect(request('/api/hermes/sessions')).rejects.toThrow('Unauthorized')
expect(hasApiKey()).toBe(false)
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
})
it('emits a global auth notice on local 403 responses', async () => {
const listener = vi.fn()
window.addEventListener('hermes-auth-notice', listener)
mockFetch.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve('Forbidden') })
await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403')
expect(listener).toHaveBeenCalledOnce()
expect(listener.mock.calls[0][0].detail).toEqual({ kind: 'forbidden' })
window.removeEventListener('hermes-auth-notice', listener)
})
it('clears token and redirects when the JWT user no longer exists', async () => {
setApiKey('stale-jwt')
mockFetch.mockResolvedValue({
ok: false,
status: 403,
text: () => Promise.resolve('{"error":"User is disabled or does not exist"}'),
})
await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403')
expect(hasApiKey()).toBe(false)
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
})
it('does NOT clear token on 401 for proxied v1 endpoints', async () => {
setApiKey('secret-key')
mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') })
await expect(request('/api/hermes/v1/runs')).rejects.toThrow('API Error 401')
expect(hasApiKey()).toBe(true)
})
it('throws error on non-401 failure', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Server Error'),
})
await expect(request('/api/hermes/sessions')).rejects.toThrow('API Error 500: Internal Server Error')
})
it('returns parsed JSON on success', async () => {
const data = { sessions: [{ id: '1' }] }
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(data) })
const result = await request('/api/hermes/sessions')
expect(result).toEqual(data)
})
})
describe('download URLs', () => {
it('adds the active profile selector to direct download URLs', () => {
setApiKey('secret-key')
localStorage.setItem('hermes_active_profile_name', 'research')
const url = new URL(getDownloadUrl('/tmp/report.txt', 'report.txt'), 'http://localhost')
expect(url.pathname).toBe('/api/hermes/download')
expect(url.searchParams.get('path')).toBe('/tmp/report.txt')
expect(url.searchParams.get('name')).toBe('report.txt')
expect(url.searchParams.get('profile')).toBe('research')
expect(url.searchParams.get('token')).toBe('secret-key')
})
})
describe('file upload', () => {
it('adds auth and active profile headers to multipart uploads', async () => {
setApiKey('secret-key')
localStorage.setItem('hermes_active_profile_name', 'research')
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ files: [] }),
})
await uploadFiles('notes', [new File(['hello'], 'hello.txt', { type: 'text/plain' })])
expect(mockFetch).toHaveBeenCalledOnce()
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('/api/hermes/files/upload?path=notes')
expect(options.method).toBe('POST')
expect(options.headers.Authorization).toBe('Bearer secret-key')
expect(options.headers['X-Hermes-Profile']).toBe('research')
expect(options.body).toBeInstanceOf(FormData)
})
})
describe('sessions API', () => {
it('sends profile-qualified targets for batch deletes', async () => {
localStorage.setItem('hermes_active_profile_name', 'research')
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ deleted: 2, failed: 0, errors: [] }),
})
await batchDeleteSessions([
{ id: 'session-default', profile: 'default' },
{ id: 'session-travel', profile: 'travel' },
])
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('/api/hermes/sessions/batch-delete')
expect(options.method).toBe('POST')
expect(options.headers['X-Hermes-Profile']).toBeUndefined()
expect(JSON.parse(options.body)).toEqual({
ids: ['session-default', 'session-travel'],
sessions: [
{ id: 'session-default', profile: 'default' },
{ id: 'session-travel', profile: 'travel' },
],
})
})
it('sends the profile selector when importing a Hermes session', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ ok: true, imported: true }),
})
await importHermesSession('cli-1', 'travel')
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('/api/hermes/sessions/hermes/cli-1/import?profile=travel')
expect(options.method).toBe('POST')
})
})
})
+492
View File
@@ -0,0 +1,492 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mockSystemApi = vi.hoisted(() => ({
checkHealth: vi.fn(),
fetchAvailableModels: vi.fn(),
addCustomModel: vi.fn(),
removeCustomModel: vi.fn(),
updateDefaultModel: vi.fn(),
updateModelAlias: vi.fn(),
updateModelVisibility: vi.fn(),
triggerUpdate: vi.fn(),
}))
vi.mock('@/api/hermes/system', () => mockSystemApi)
vi.mock('@/api/client', () => ({ hasApiKey: () => true }))
import { useAppStore } from '@/stores/hermes/app'
describe('App Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockSystemApi.addCustomModel.mockResolvedValue({ success: true, custom_models: {} })
mockSystemApi.removeCustomModel.mockResolvedValue({ success: true, custom_models: {} })
window.localStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
it('persists desktop sidebar collapsed state to localStorage', () => {
const store = useAppStore()
expect(store.sidebarCollapsed).toBe(false)
store.toggleSidebarCollapsed()
expect(store.sidebarCollapsed).toBe(true)
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('1')
store.toggleSidebarCollapsed()
expect(store.sidebarCollapsed).toBe(false)
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('0')
})
it('loads model visibility and falls back when the configured default is hidden', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'deepseek-chat',
default_provider: 'deepseek',
groups: [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-reasoner'],
},
],
allProviders: [],
model_visibility: {
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
},
})
const store = useAppStore()
await store.loadModels()
expect(store.modelVisibility).toEqual({
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
})
expect(store.selectedModel).toBe('deepseek-reasoner')
expect(store.selectedProvider).toBe('deepseek')
expect(store.customModels).toEqual({})
expect(store.isModelVisible('deepseek', 'deepseek-reasoner')).toBe(true)
expect(store.isModelVisible('deepseek', 'deepseek-chat')).toBe(false)
})
it('loads aliases while falling back from a hidden default without rehydrating it as custom', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'deepseek-chat',
default_provider: 'deepseek',
groups: [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-reasoner'],
available_models: ['deepseek-chat', 'deepseek-reasoner'],
},
],
allProviders: [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-chat', 'deepseek-reasoner'],
},
],
model_aliases: {
deepseek: { 'deepseek-reasoner': 'Reasoner Alias' },
},
model_visibility: {
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
},
})
const store = useAppStore()
await store.loadModels()
expect(store.modelAliases).toEqual({
deepseek: { 'deepseek-reasoner': 'Reasoner Alias' },
})
expect(store.modelVisibility).toEqual({
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
})
expect(store.selectedModel).toBe('deepseek-reasoner')
expect(store.selectedProvider).toBe('deepseek')
expect(store.displayModelName('deepseek-reasoner', 'deepseek')).toBe('Reasoner Alias')
expect(store.customModels).toEqual({})
})
it('persists model visibility without changing the canonical selected model id', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'deepseek-reasoner',
default_provider: 'deepseek',
groups: [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-reasoner'],
},
],
allProviders: [],
model_visibility: {
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
},
})
mockSystemApi.updateModelVisibility.mockResolvedValue({
success: true,
model_visibility: {
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
},
})
const store = useAppStore()
await store.setModelVisibility('deepseek', { mode: 'include', models: ['deepseek-reasoner'] })
expect(mockSystemApi.updateModelVisibility).toHaveBeenCalledWith({
provider: 'deepseek',
mode: 'include',
models: ['deepseek-reasoner'],
})
expect(store.selectedModel).toBe('deepseek-reasoner')
expect(store.selectedProvider).toBe('deepseek')
expect(mockSystemApi.updateDefaultModel).not.toHaveBeenCalled()
})
it('marks the client stale when the served Web UI version changes', async () => {
mockSystemApi.checkHealth.mockResolvedValue({
status: 'ok',
webui_version: '0.5.17',
webui_latest: '0.5.17',
webui_update_available: false,
})
const store = useAppStore()
await store.checkConnection()
expect(store.connected).toBe(true)
expect(store.serverVersion).toBe('0.5.17')
expect(store.clientOutdated).toBe(true)
expect(store.updateAvailable).toBe(false)
})
it('does not mark the client stale when the served Web UI version matches this bundle', async () => {
mockSystemApi.checkHealth.mockResolvedValue({
status: 'ok',
webui_version: 'test',
webui_latest: 'test',
webui_update_available: false,
})
const store = useAppStore()
await store.checkConnection()
expect(store.serverVersion).toBe('test')
expect(store.clientOutdated).toBe(false)
})
it('clears the updating state and reports failure when self-update request fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSystemApi.triggerUpdate.mockRejectedValue(new Error('install failed'))
const store = useAppStore()
const ok = await store.doUpdate()
expect(ok).toBe(false)
expect(store.updating).toBe(false)
expect(consoleError).toHaveBeenCalledWith('Failed to update Hermes Web UI:', expect.any(Error))
consoleError.mockRestore()
})
it('loads model aliases and resolves display names without changing canonical IDs', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'deepseek-v4-flash',
default_provider: 'deepseek',
groups: [{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-v4-flash'],
api_key: '',
}],
allProviders: [],
model_aliases: {
deepseek: { 'deepseek-v4-flash': 'Flash Alias' },
},
})
const store = useAppStore()
await store.loadModels()
expect(store.selectedModel).toBe('deepseek-v4-flash')
expect(store.getModelAlias('deepseek-v4-flash', 'deepseek')).toBe('Flash Alias')
expect(store.displayModelName('deepseek-v4-flash', 'deepseek')).toBe('Flash Alias')
expect(store.displayModelName('unknown', 'deepseek')).toBe('unknown')
})
it('selects the browser active profile default instead of the aggregate response default', async () => {
window.localStorage.setItem('hermes_active_profile_name', 'tester')
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'glm-5-turbo',
default_provider: 'custom:glm-coding-plan',
groups: [{
provider: 'custom:glm-coding-plan',
label: 'glm-coding-plan',
base_url: 'https://api.z.ai/api/anthropic',
models: ['glm-5-turbo', 'glm-5.1'],
api_key: '',
}],
allProviders: [],
profiles: [
{
profile: 'default',
default: 'glm-5-turbo',
default_provider: 'custom:glm-coding-plan',
groups: [{
provider: 'custom:glm-coding-plan',
label: 'glm-coding-plan',
base_url: 'https://api.z.ai/api/anthropic',
models: ['glm-5-turbo', 'glm-5.1'],
api_key: '',
}],
},
{
profile: 'tester',
default: 'claude-opus-4-6',
default_provider: 'custom:subrouter',
groups: [{
provider: 'custom:subrouter',
label: 'subrouter',
base_url: 'https://subrouter.ai/v1',
models: ['claude-opus-4-6', 'gpt-5.5'],
api_key: '',
}],
},
],
})
const store = useAppStore()
await store.loadModels()
expect(store.selectedModel).toBe('claude-opus-4-6')
expect(store.selectedProvider).toBe('custom:subrouter')
})
it('does not refetch available models within the cache window after an empty response', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: '',
default_provider: '',
groups: [],
allProviders: [],
})
const store = useAppStore()
await store.loadModels()
await store.loadModels()
expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalledTimes(1)
})
it('waits only up to the run timeout for the first available models request', async () => {
vi.useFakeTimers()
mockSystemApi.fetchAvailableModels.mockReturnValue(new Promise(() => {}))
const store = useAppStore()
let resolved = false
const waitPromise = store.waitForModelsForRun(15000).then(() => {
resolved = true
})
expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(14999)
expect(resolved).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await waitPromise
expect(resolved).toBe(true)
expect(store.modelGroups).toEqual([])
})
it('keeps aliases scoped to their provider when model IDs overlap', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'shared-model',
default_provider: 'provider-a',
groups: [
{
provider: 'provider-a',
label: 'Provider A',
base_url: 'https://a.example/v1',
models: ['shared-model'],
api_key: '',
},
{
provider: 'provider-b',
label: 'Provider B',
base_url: 'https://b.example/v1',
models: ['shared-model'],
api_key: '',
},
],
allProviders: [],
model_aliases: {
'provider-a': { 'shared-model': 'A Alias' },
},
})
const store = useAppStore()
await store.loadModels()
expect(store.displayModelName('shared-model', 'provider-a')).toBe('A Alias')
expect(store.displayModelName('shared-model', 'provider-b')).toBe('shared-model')
expect(store.displayModelName('shared-model')).toBe('A Alias')
})
it('rehydrates an active unlisted default model as removable after loading models', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'manually-supported-id',
default_provider: 'deepseek',
groups: [{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-v4-flash'],
api_key: '',
}],
allProviders: [],
model_aliases: {},
})
const store = useAppStore()
await store.loadModels()
expect(store.selectedModel).toBe('manually-supported-id')
expect(store.customModels).toEqual({ deepseek: ['manually-supported-id'] })
})
it('loads persisted custom models from the server response', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'gemma-4-26b-a4b-it',
default_provider: 'google-ai-studio',
groups: [{
provider: 'google-ai-studio',
label: 'Google AI Studio',
base_url: 'https://generativelanguage.googleapis.com/v1beta',
models: ['gemma-4-26b-a4b-it'],
api_key: '',
}],
allProviders: [],
custom_models: {
'google-ai-studio': ['gemma-4-26b-a4b-it'],
},
})
const store = useAppStore()
await store.loadModels()
expect(store.selectedModel).toBe('gemma-4-26b-a4b-it')
expect(store.customModels).toEqual({
'google-ai-studio': ['gemma-4-26b-a4b-it'],
})
})
it('saves and clears model aliases via the Web UI-only alias API', async () => {
mockSystemApi.updateModelAlias.mockResolvedValue(undefined)
const store = useAppStore()
await store.setModelAlias('deepseek-v4-flash', 'deepseek', ' Flash Alias ')
expect(mockSystemApi.updateModelAlias).toHaveBeenCalledWith({
provider: 'deepseek',
model: 'deepseek-v4-flash',
alias: 'Flash Alias',
})
expect(store.modelAliases).toEqual({ deepseek: { 'deepseek-v4-flash': 'Flash Alias' } })
await store.setModelAlias('deepseek-v4-flash', 'deepseek', '')
expect(store.modelAliases).toEqual({})
})
it('removes an unlisted custom model and falls back to a listed model when active', async () => {
mockSystemApi.updateDefaultModel.mockResolvedValue(undefined)
const store = useAppStore()
store.modelGroups = [{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-v4-flash'],
api_key: '',
}]
mockSystemApi.addCustomModel.mockResolvedValue({
success: true,
custom_models: { deepseek: ['test'] },
})
mockSystemApi.removeCustomModel.mockResolvedValue({
success: true,
custom_models: {},
})
await store.switchModel('test', 'deepseek')
expect(store.selectedModel).toBe('test')
expect(store.customModels).toEqual({ deepseek: ['test'] })
expect(mockSystemApi.addCustomModel).toHaveBeenCalledWith({
provider: 'deepseek',
model: 'test',
})
await store.removeCustomModel('test', 'deepseek')
expect(store.customModels).toEqual({})
expect(mockSystemApi.removeCustomModel).toHaveBeenCalledWith({
provider: 'deepseek',
model: 'test',
})
expect(store.selectedModel).toBe('deepseek-v4-flash')
expect(mockSystemApi.updateDefaultModel).toHaveBeenLastCalledWith({
default: 'deepseek-v4-flash',
provider: 'deepseek',
})
})
it('removes deleted custom models from loaded model groups immediately', async () => {
mockSystemApi.removeCustomModel.mockResolvedValue({
success: true,
custom_models: {},
})
const store = useAppStore()
store.customModels = { deepseek: ['manual-model'] }
store.modelGroups = [{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-v4-flash', 'manual-model'],
available_models: ['deepseek-v4-flash', 'manual-model'],
api_key: '',
}]
store.profileModelGroups = [{
profile: 'default',
default: 'deepseek-v4-flash',
default_provider: 'deepseek',
groups: [{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-v4-flash', 'manual-model'],
available_models: ['deepseek-v4-flash', 'manual-model'],
api_key: '',
}],
}]
await store.removeCustomModel('manual-model', 'deepseek')
expect(store.modelGroups[0].models).toEqual(['deepseek-v4-flash'])
expect(store.modelGroups[0].available_models).toEqual(['deepseek-v4-flash'])
expect(store.profileModelGroups[0].groups[0].models).toEqual(['deepseek-v4-flash'])
expect(store.profileModelGroups[0].groups[0].available_models).toEqual(['deepseek-v4-flash'])
})
})
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { nextTick } from 'vue'
import { useChatStore } from '@/stores/hermes/chat'
import ChatInput from '@/components/hermes/chat/ChatInput.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('naive-ui', () => ({
NButton: { template: '<button type="button" v-bind="$attrs"><slot /><slot name="icon" /></button>' },
NTooltip: { template: '<div><slot name="trigger" /><slot /></div>' },
NSwitch: { template: '<button type="button"></button>' },
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
NInputNumber: { template: '<input />' },
useMessage: () => ({ error: vi.fn(), success: vi.fn() }),
}))
vi.mock('@/api/hermes/sessions', () => ({
fetchContextLength: vi.fn().mockResolvedValue(256000),
}))
vi.mock('@/api/hermes/model-context', () => ({
setModelContext: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useToolTraceVisibility', () => ({
useToolTraceVisibility: () => ({ toolTraceVisible: { value: true }, toggleToolTraceVisible: vi.fn() }),
}))
function mountForSession(sessionId: string) {
const pinia = createTestingPinia({ stubActions: false, createSpy: vi.fn })
const chatStore = useChatStore()
chatStore.sessions = [
{ id: sessionId, title: sessionId, source: 'cli', messages: [], createdAt: Date.now(), updatedAt: Date.now() },
]
chatStore.activeSessionId = sessionId
chatStore.activeSession = chatStore.sessions[0]
return mount(ChatInput, { global: { plugins: [pinia] } })
}
describe('ChatInput draft persistence', () => {
beforeEach(() => {
localStorage.clear()
})
it('restores unsent text for the active session after the chat view is remounted', async () => {
const wrapper = mountForSession('session-a')
const textarea = wrapper.get('textarea')
await textarea.setValue('draft before tab switch')
await nextTick()
wrapper.unmount()
const remounted = mountForSession('session-a')
await nextTick()
expect((remounted.get('textarea').element as HTMLTextAreaElement).value).toBe('draft before tab switch')
})
it('stores drafts under one localStorage key mapped by session id', async () => {
const wrapperA = mountForSession('session-a')
await wrapperA.get('textarea').setValue('draft for session a')
await nextTick()
wrapperA.unmount()
const wrapperB = mountForSession('session-b')
await wrapperB.get('textarea').setValue('draft for session b')
await nextTick()
wrapperB.unmount()
expect(localStorage.getItem('hermes_chat_input_draft_v1')).toBeNull()
expect(JSON.parse(localStorage.getItem('hermes_chat_input_drafts_v1') || '{}')).toEqual({
'session-a': 'draft for session a',
'session-b': 'draft for session b',
})
const remountedA = mountForSession('session-a')
await nextTick()
expect((remountedA.get('textarea').element as HTMLTextAreaElement).value).toBe('draft for session a')
})
})
+192
View File
@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const socketState = vi.hoisted(() => ({
sockets: [] as any[],
}))
vi.mock('socket.io-client', () => {
function createSocket() {
const listeners = new Map<string, Set<(...args: any[]) => void>>()
const addListener = (event: string, handler: (...args: any[]) => void) => {
if (!listeners.has(event)) listeners.set(event, new Set())
listeners.get(event)!.add(handler)
}
const removeListener = (event: string, handler: (...args: any[]) => void) => {
const eventListeners = listeners.get(event)
if (!eventListeners) return
for (const candidate of [...eventListeners]) {
if (candidate === handler || (candidate as any).__original === handler) {
eventListeners.delete(candidate)
}
}
}
const socket: any = {
connected: true,
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
addListener(event, handler)
return socket
}),
once: vi.fn((event: string, handler: (...args: any[]) => void) => {
const wrapped = (...args: any[]) => {
removeListener(event, wrapped)
handler(...args)
}
;(wrapped as any).__original = handler
addListener(event, wrapped)
return socket
}),
off: vi.fn((event: string, handler: (...args: any[]) => void) => {
removeListener(event, handler)
return socket
}),
removeListener: vi.fn((event: string, handler: (...args: any[]) => void) => {
removeListener(event, handler)
return socket
}),
removeAllListeners: vi.fn(() => {
listeners.clear()
return socket
}),
emit: vi.fn(),
disconnect: vi.fn(() => {
socket.connected = false
}),
__listenerCount: (event: string) => listeners.get(event)?.size || 0,
__trigger: (event: string, ...args: any[]) => {
if (event === 'connect') socket.connected = true
if (event === 'disconnect') socket.connected = false
for (const handler of [...(listeners.get(event) || [])]) handler(...args)
},
}
return socket
}
return {
io: vi.fn(() => {
const socket = createSocket()
socketState.sockets.push(socket)
return socket
}),
}
})
vi.mock('../../packages/client/src/api/client', () => ({
getApiKey: () => 'test-token',
getBaseUrlValue: () => '',
}))
describe('chat-run socket reconnect handling', () => {
beforeEach(() => {
vi.resetModules()
socketState.sockets = []
})
it('keeps transient mobile disconnects alive and resumes after reconnect', async () => {
const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const onEvent = vi.fn()
const onDone = vi.fn()
const onError = vi.fn()
const onReconnectResume = vi.fn()
startRunViaSocket(
{ session_id: 'session-1', input: 'hello', profile: 'default', source: 'cli' },
onEvent,
onDone,
onError,
undefined,
{ onReconnectResume },
)
const socket = socketState.sockets[0]
expect(socket.emit).toHaveBeenCalledWith('run', expect.objectContaining({ session_id: 'session-1' }))
socket.__trigger('disconnect', 'ping timeout')
expect(onError).not.toHaveBeenCalled()
socket.__trigger('connect_error', new Error('temporary reconnect failure'))
expect(onError).not.toHaveBeenCalled()
socket.__trigger('connect')
expect(socket.emit).toHaveBeenCalledWith('resume', { session_id: 'session-1', profile: 'default' })
const resumed = { session_id: 'session-1', messages: [], isWorking: true, events: [] }
socket.__trigger('resumed', resumed)
expect(onReconnectResume).toHaveBeenCalledWith(resumed)
socket.__trigger('message.delta', { event: 'message.delta', session_id: 'session-1', delta: 'after reconnect' })
expect(onEvent).toHaveBeenCalledWith({ event: 'message.delta', session_id: 'session-1', delta: 'after reconnect' })
expect(onDone).not.toHaveBeenCalled()
})
it('keeps fatal disconnects fatal and removes per-run listeners', async () => {
const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const onError = vi.fn()
startRunViaSocket(
{ session_id: 'session-1', input: 'hello', profile: 'default', source: 'cli' },
vi.fn(),
vi.fn(),
onError,
)
const socket = socketState.sockets[0]
socket.__trigger('disconnect', 'io server disconnect')
expect(onError).toHaveBeenCalledOnce()
expect(onError.mock.calls[0][0].message).toBe('Socket disconnected: io server disconnect')
expect(socket.__listenerCount('connect')).toBe(0)
expect(socket.__listenerCount('disconnect')).toBe(0)
expect(socket.__listenerCount('connect_error')).toBe(0)
})
it('does not attach extra reconnect listeners when the session already has handlers', async () => {
const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const body = { session_id: 'session-1', input: 'hello', profile: 'default', source: 'cli' as const }
startRunViaSocket(body, vi.fn(), vi.fn(), vi.fn())
const socket = socketState.sockets[0]
expect(socket.__listenerCount('connect')).toBe(1)
expect(socket.__listenerCount('disconnect')).toBe(1)
startRunViaSocket(body, vi.fn(), vi.fn(), vi.fn())
expect(socket.__listenerCount('connect')).toBe(1)
expect(socket.__listenerCount('disconnect')).toBe(1)
expect(socket.emit).toHaveBeenCalledWith('run', body)
})
it('fans session.command events to run-local and global handlers', async () => {
const { onSessionCommand, startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const onEvent = vi.fn()
const onGlobalCommand = vi.fn()
const offGlobalCommand = onSessionCommand(onGlobalCommand)
startRunViaSocket(
{ session_id: 'session-1', input: '/goal status', profile: 'default', source: 'cli' },
onEvent,
vi.fn(),
vi.fn(),
)
const socket = socketState.sockets[0]
const event = {
event: 'session.command',
session_id: 'session-1',
command: 'goal',
action: 'status',
message: 'Goal (active, 0/20 turns): write site',
}
socket.__trigger('session.command', event)
expect(onEvent).toHaveBeenCalledWith(event)
expect(onGlobalCommand).toHaveBeenCalledWith(event)
offGlobalCommand()
socket.__trigger('session.command', { ...event, message: 'next status' })
expect(onGlobalCommand).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const chatApi = vi.hoisted(() => ({
resumeSession: vi.fn(),
registerSessionHandlers: vi.fn(),
unregisterSessionHandlers: vi.fn(),
}))
vi.mock('@/api/hermes/chat', () => ({
startRunViaSocket: vi.fn(),
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()),
}))
vi.mock('@/api/client', () => ({
getActiveProfileName: () => 'default',
}))
vi.mock('@/api/hermes/sessions', () => ({
deleteSession: vi.fn(),
fetchSession: vi.fn(),
fetchSessions: vi.fn(),
setSessionModel: vi.fn(),
}))
vi.mock('@/api/hermes/download', () => ({
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
}))
vi.mock('@/utils/completion-sound', () => ({
primeCompletionSound: vi.fn(),
playCompletionSound: vi.fn(),
}))
import { useChatStore, type Session } from '@/stores/hermes/chat'
function makeSession(id: string): Session {
return {
id,
title: id,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
describe('chat store compression state', () => {
beforeEach(() => {
vi.resetAllMocks()
setActivePinia(createPinia())
chatApi.resumeSession.mockImplementation((sessionId: string, onResumed: (data: any) => void) => {
onResumed({
session_id: sessionId,
messages: [],
isWorking: sessionId === 'session-1',
events: [],
})
return {} as any
})
})
it('does not show a background session compression indicator in the active session', async () => {
const store = useChatStore()
store.sessions = [makeSession('session-1'), makeSession('session-2')]
await store.switchSession('session-1')
const handlers = chatApi.registerSessionHandlers.mock.calls.find(call => call[0] === 'session-1')?.[1]
expect(handlers).toBeTruthy()
await store.switchSession('session-2')
handlers.onCompressionStarted({
event: 'compression.started',
session_id: 'session-1',
message_count: 6,
token_count: 1234,
})
expect(store.activeSessionId).toBe('session-2')
expect(store.compressionState).toBeNull()
await store.switchSession('session-1')
expect(store.compressionState).toEqual(expect.objectContaining({
compressing: true,
messageCount: 6,
beforeTokens: 1234,
}))
})
})
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const chatApi = vi.hoisted(() => ({
registerSessionHandlers: vi.fn(),
unregisterSessionHandlers: vi.fn(),
getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })),
sessionCommandHandlers: [] as Array<(event: any) => void>,
peerUserMessageHandlers: [] as Array<(event: any) => void>,
}))
vi.mock('@/api/hermes/chat', () => ({
startRunViaSocket: vi.fn(),
resumeSession: vi.fn(),
registerSessionHandlers: chatApi.registerSessionHandlers,
unregisterSessionHandlers: chatApi.unregisterSessionHandlers,
getChatRunSocket: chatApi.getChatRunSocket,
respondToolApproval: vi.fn(),
respondClarify: vi.fn(),
onPeerUserMessage: vi.fn((handler: (event: any) => void) => {
chatApi.peerUserMessageHandlers.push(handler)
return vi.fn()
}),
onSessionCommand: vi.fn((handler: (event: any) => void) => {
chatApi.sessionCommandHandlers.push(handler)
return vi.fn()
}),
}))
vi.mock('@/api/client', () => ({
getActiveProfileName: () => 'default',
}))
vi.mock('@/api/hermes/sessions', () => ({
deleteSession: vi.fn(),
fetchSession: vi.fn(),
fetchSessions: vi.fn(),
setSessionModel: vi.fn(),
}))
vi.mock('@/api/hermes/download', () => ({
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
}))
vi.mock('@/utils/completion-sound', () => ({
primeCompletionSound: vi.fn(),
playCompletionSound: vi.fn(),
}))
import { useChatStore, type Session } from '@/stores/hermes/chat'
function makeSession(): Session {
return {
id: 'session-1',
title: 'session',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
describe('chat store session.command fanout', () => {
beforeEach(() => {
vi.resetAllMocks()
chatApi.sessionCommandHandlers = []
chatApi.peerUserMessageHandlers = []
setActivePinia(createPinia())
})
it('attaches to a goal resume run started from another window', () => {
const store = useChatStore()
const session = makeSession()
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
expect(chatApi.sessionCommandHandlers).toHaveLength(1)
chatApi.sessionCommandHandlers[0]({
event: 'session.command',
session_id: 'session-1',
command: 'goal',
action: 'resume',
message: 'Goal resumed',
started: true,
terminal: false,
})
expect(store.isStreaming).toBe(true)
expect(chatApi.registerSessionHandlers).toHaveBeenCalledWith('session-1', expect.objectContaining({
onRunStarted: expect.any(Function),
onSessionCommand: expect.any(Function),
}))
expect(store.messages).toEqual([
expect.objectContaining({
role: 'command',
content: 'Goal resumed',
commandAction: 'resume',
}),
])
})
it('does not clear the transcript for goal done commands', () => {
const store = useChatStore()
const session = makeSession()
session.messages = [
{ id: 'user-1', role: 'user', content: 'keep me', timestamp: 1 },
]
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
chatApi.sessionCommandHandlers[0]({
event: 'session.command',
session_id: 'session-1',
command: 'goal',
action: 'clear',
message: 'Goal cleared.',
terminal: true,
})
expect(store.messages).toEqual([
expect.objectContaining({ id: 'user-1', content: 'keep me' }),
expect.objectContaining({
role: 'command',
content: 'Goal cleared.',
commandAction: 'clear',
}),
])
})
})
+90
View File
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useChatStore } from '@/stores/hermes/chat'
describe('chat store thinkingObservation', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts empty', () => {
const store = useChatStore()
expect(store.getThinkingObservation('any-id')).toBeUndefined()
})
it('records startedAt when delta first introduces an opening tag', () => {
const store = useChatStore()
store.noteThinkingDelta('msg-1', '', '<think>hi')
const ob = store.getThinkingObservation('msg-1')
expect(ob).toBeDefined()
expect(typeof ob!.startedAt).toBe('number')
expect(ob!.endedAt).toBeUndefined()
})
it('records endedAt when delta first introduces closing tag', () => {
const store = useChatStore()
store.noteThinkingDelta('msg-1', '', '<think>hi')
store.noteThinkingDelta('msg-1', '<think>hi', '<think>hi</think>done')
const ob = store.getThinkingObservation('msg-1')
expect(ob!.startedAt).toBeDefined()
expect(typeof ob!.endedAt).toBe('number')
})
it('is idempotent for subsequent openings/closings', () => {
const store = useChatStore()
store.noteThinkingDelta('m', '', '<think>a</think>')
const first = store.getThinkingObservation('m')!
const firstStarted = first.startedAt
const firstEnded = first.endedAt
store.noteThinkingDelta(
'm',
'<think>a</think>',
'<think>a</think><think>b</think>',
)
const second = store.getThinkingObservation('m')!
expect(second.startedAt).toBe(firstStarted)
expect(second.endedAt).toBe(firstEnded)
})
it('is ignored when delta is inside a code block', () => {
const store = useChatStore()
store.noteThinkingDelta('m', '', '```\n<think>fake</think>\n```')
expect(store.getThinkingObservation('m')).toBeUndefined()
})
it('clears observations on clearThinkingObservationFor', () => {
const store = useChatStore()
store.noteThinkingDelta('m', '', '<think>hi</think>')
expect(store.getThinkingObservation('m')).toBeDefined()
store.clearThinkingObservationFor('any-session')
expect(store.getThinkingObservation('m')).toBeUndefined()
})
it('noteReasoningStart records startedAt only once', () => {
const store = useChatStore()
store.noteReasoningStart('r1')
const t1 = store.getThinkingObservation('r1')!.startedAt
expect(typeof t1).toBe('number')
store.noteReasoningStart('r1')
expect(store.getThinkingObservation('r1')!.startedAt).toBe(t1)
})
it('noteReasoningEnd requires prior start', () => {
const store = useChatStore()
store.noteReasoningEnd('r2')
expect(store.getThinkingObservation('r2')).toBeUndefined()
store.noteReasoningStart('r2')
store.noteReasoningEnd('r2')
expect(store.getThinkingObservation('r2')!.endedAt).toBeDefined()
})
it('noteReasoningEnd is idempotent', () => {
const store = useChatStore()
store.noteReasoningStart('r3')
store.noteReasoningEnd('r3')
const end1 = store.getThinkingObservation('r3')!.endedAt
store.noteReasoningEnd('r3')
expect(store.getThinkingObservation('r3')!.endedAt).toBe(end1)
})
})
+81
View File
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetCompletionSoundForTests, playCompletionSound, primeCompletionSound } from '@/utils/completion-sound'
function installMockAudioContext(initialState: AudioContextState = 'running') {
const oscillator = {
type: 'sine' as OscillatorType,
frequency: {
setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
}
const gain = {
gain: {
setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
}
const context = {
state: initialState,
currentTime: 10,
destination: {},
resume: vi.fn(async () => {
context.state = 'running'
}),
createOscillator: vi.fn(() => oscillator),
createGain: vi.fn(() => gain),
}
const AudioContextMock = vi.fn(() => context)
Object.defineProperty(window, 'AudioContext', {
configurable: true,
writable: true,
value: AudioContextMock,
})
return { AudioContextMock, context, oscillator, gain }
}
describe('completion sound', () => {
beforeEach(() => {
__resetCompletionSoundForTests()
vi.restoreAllMocks()
Object.defineProperty(window, 'AudioContext', {
configurable: true,
writable: true,
value: undefined,
})
})
it('returns false when Web Audio is unavailable', async () => {
await expect(playCompletionSound()).resolves.toBe(false)
})
it('primes a suspended audio context from user interaction', () => {
const { context } = installMockAudioContext('suspended')
primeCompletionSound()
expect(context.resume).toHaveBeenCalledTimes(1)
})
it('plays a short tone through Web Audio', async () => {
const { context, oscillator, gain } = installMockAudioContext('running')
await expect(playCompletionSound()).resolves.toBe(true)
expect(context.createOscillator).toHaveBeenCalledTimes(1)
expect(context.createGain).toHaveBeenCalledTimes(1)
expect(oscillator.connect).toHaveBeenCalledWith(gain)
expect(gain.connect).toHaveBeenCalledWith(context.destination)
expect(oscillator.start).toHaveBeenCalledWith(10)
expect(oscillator.stop).toHaveBeenCalledWith(10.16)
})
})
@@ -0,0 +1,177 @@
// @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)
})
})
+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')
})
})
+133
View File
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
const mockApi = vi.hoisted(() => ({
startCopilotLogin: vi.fn(),
pollCopilotLogin: vi.fn(),
}))
const mockMessage = vi.hoisted(() => ({
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
}))
vi.mock('@/api/hermes/copilot-auth', () => mockApi)
vi.mock('@/utils/clipboard', () => ({ copyToClipboard: vi.fn(async () => true) }))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (k: string) => k }),
}))
vi.mock('naive-ui', () => ({
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
NButton: { template: '<button @click="$emit(\'click\')"><slot /></button>' },
NSpin: { template: '<span class="spin" />' },
useMessage: () => mockMessage,
}))
import CopilotLoginModal from '@/components/hermes/models/CopilotLoginModal.vue'
function mountModal() {
return mount(CopilotLoginModal)
}
describe('CopilotLoginModal device-flow state machine', () => {
beforeEach(() => {
vi.useFakeTimers()
mockApi.startCopilotLogin.mockReset()
mockApi.pollCopilotLogin.mockReset()
mockMessage.success.mockReset()
mockMessage.warning.mockReset()
mockMessage.error.mockReset()
})
it('启动后进入 waiting 并显示 user_code', async () => {
mockApi.startCopilotLogin.mockResolvedValue({
session_id: 'sess-1',
user_code: 'ABCD-1234',
verification_url: 'https://github.com/login/device',
expires_in: 900,
interval: 5,
})
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'pending', error: null })
const wrapper = mountModal()
await flushPromises()
expect(wrapper.text()).toContain('ABCD-1234')
expect(mockApi.startCopilotLogin).toHaveBeenCalledTimes(1)
})
it('approved 时 emit success 且消息为 copilotApproved', async () => {
mockApi.startCopilotLogin.mockResolvedValue({
session_id: 'sess-2',
user_code: 'WXYZ-9999',
verification_url: 'https://github.com/login/device',
expires_in: 900,
interval: 5,
})
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'approved', error: null })
const wrapper = mountModal()
await flushPromises()
// 推动一次 poll timer
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(mockMessage.success).toHaveBeenCalledWith('models.copilotApproved')
// approved 后 1s 自动关闭
await vi.advanceTimersByTimeAsync(1500)
await flushPromises()
expect(wrapper.emitted('success')).toBeTruthy()
})
it('expired 时进入 expired 状态并显示重试按钮', async () => {
mockApi.startCopilotLogin.mockResolvedValue({
session_id: 'sess-3',
user_code: 'EXPI-RED!',
verification_url: 'https://github.com/login/device',
expires_in: 900,
interval: 5,
})
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'expired', error: null })
const wrapper = mountModal()
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(wrapper.text()).toContain('models.copilotExpired')
expect(wrapper.emitted('success')).toBeFalsy()
})
it('startCopilotLogin 抛错时显示 error 且不 emit success', async () => {
mockApi.startCopilotLogin.mockRejectedValue(new Error('boom'))
const wrapper = mountModal()
await flushPromises()
expect(mockMessage.error).toHaveBeenCalled()
expect(wrapper.emitted('success')).toBeFalsy()
})
it('denied 时进入 error 状态', async () => {
mockApi.startCopilotLogin.mockResolvedValue({
session_id: 'sess-4',
user_code: 'NOPE',
verification_url: 'https://github.com/login/device',
expires_in: 900,
interval: 5,
})
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'denied', error: null })
const wrapper = mountModal()
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(wrapper.text()).toContain('models.copilotDenied')
expect(wrapper.emitted('success')).toBeFalsy()
})
})
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
const mockPush = vi.hoisted(() => vi.fn())
const mockFetchCurrentUser = vi.hoisted(() => vi.fn())
const mockGetApiKey = vi.hoisted(() => vi.fn())
const routeState = vi.hoisted(() => ({ fullPath: '/hermes/chat', name: 'hermes.chat' as any }))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({ push: mockPush }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/api/auth', () => ({
fetchCurrentUser: mockFetchCurrentUser,
}))
vi.mock('@/api/client', () => ({
getApiKey: mockGetApiKey,
}))
vi.mock('naive-ui', async () => {
const { defineComponent, h } = await import('vue')
return {
NModal: defineComponent({
props: { show: Boolean, title: String },
setup(props, { slots }) {
return () => props.show
? h('div', { class: 'modal' }, [
h('h2', props.title),
slots.default?.(),
h('div', { class: 'modal-actions' }, slots.action?.()),
])
: null
},
}),
NButton: defineComponent({
emits: ['click'],
setup(_props, { emit, slots }) {
return () => h('button', { onClick: () => emit('click') }, slots.default?.())
},
}),
}
})
import DefaultCredentialPrompt from '@/components/auth/DefaultCredentialPrompt.vue'
describe('DefaultCredentialPrompt', () => {
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
routeState.fullPath = '/hermes/chat'
routeState.name = 'hermes.chat'
mockGetApiKey.mockReturnValue('jwt-token')
})
it('prompts after login when the current user still has default credentials', async () => {
mockFetchCurrentUser.mockResolvedValue({
id: 1,
username: 'admin',
role: 'super_admin',
status: 'active',
created_at: 1,
updated_at: 1,
last_login_at: 1,
requiresCredentialChange: true,
})
const wrapper = mount(DefaultCredentialPrompt)
await flushPromises()
await nextTick()
expect(mockFetchCurrentUser).toHaveBeenCalledOnce()
expect(wrapper.text()).toContain('login.defaultCredentialMessage')
await wrapper.findAll('button')[1].trigger('click')
expect(mockPush).toHaveBeenCalledWith({ name: 'hermes.settings', query: { tab: 'account' } })
})
it('does not prompt on the login route', async () => {
routeState.fullPath = '/'
routeState.name = 'login'
mount(DefaultCredentialPrompt)
await Promise.resolve()
expect(mockFetchCurrentUser).not.toHaveBeenCalled()
})
})
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { getClipboardPathForEntry } from '@/utils/file-path'
const baseEntry = {
name: 'app.log',
path: 'logs/app.log',
isDir: false,
size: 12,
modTime: '2026-05-20T00:00:00.000Z',
}
describe('file path clipboard helpers', () => {
it('prefers absolute path metadata when available', () => {
expect(getClipboardPathForEntry({
...baseEntry,
absolutePath: '/home/agent/.hermes/logs/app.log',
})).toBe('/home/agent/.hermes/logs/app.log')
})
it('falls back to the relative operation path for older API responses', () => {
expect(getClipboardPathForEntry(baseEntry)).toBe('logs/app.log')
})
})
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { buildMentionOptions } from '@/components/hermes/group-chat/mention-options'
describe('group chat mention options', () => {
const agents = [
{ name: 'Alice', profile: 'alice-profile' },
{ name: 'Bob', profile: 'bob-profile' },
{ name: 'all', profile: 'literal-all-agent' },
]
it('offers @all before agent mentions when the mention query is empty', () => {
expect(buildMentionOptions(agents, '').map(option => option.key)).toEqual([
'special:all',
'agent:Alice',
'agent:Bob',
])
})
it('keeps @all reserved when filtering by all and hides a literal all agent', () => {
expect(buildMentionOptions(agents, 'all')).toEqual([
{
key: 'special:all',
type: 'all',
name: 'all',
label: '@all',
description: 'All agents',
},
])
})
it('filters normal agent mentions without showing @all for unrelated queries', () => {
expect(buildMentionOptions(agents, 'bo').map(option => option.key)).toEqual(['agent:Bob'])
})
})
@@ -0,0 +1,224 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { ChatMessage, RoomInfo } from '@/api/hermes/group-chat'
const groupChatApiMock = vi.hoisted(() => {
const handlers = new Map<string, Function[]>()
const socket: any = {
connected: true,
id: 'socket-1',
on: vi.fn((event: string, cb: Function) => {
const existing = handlers.get(event) || []
existing.push(cb)
handlers.set(event, existing)
return socket
}),
emit: vi.fn((event: string, _data?: unknown, ack?: Function) => {
if (event === 'join' && ack) ack({ members: [], agents: [], typingUsers: [], contextStatuses: [] })
return socket
}),
disconnect: vi.fn(),
}
return {
handlers,
socket,
connectGroupChat: vi.fn(() => socket),
disconnectGroupChat: vi.fn(),
getSocket: vi.fn(() => socket),
getStoredUserId: vi.fn(() => 'user-1'),
getStoredUserName: vi.fn(() => 'tester'),
createRoom: vi.fn(),
listRooms: vi.fn(),
getRoomDetail: vi.fn(),
joinRoomByCode: vi.fn(),
addAgent: vi.fn(),
listAgents: vi.fn(),
removeAgent: vi.fn(),
cloneRoom: vi.fn(),
deleteRoom: vi.fn(),
clearRoomContext: vi.fn(),
}
})
vi.mock('@/api/hermes/group-chat', () => groupChatApiMock)
vi.mock('@/api/client', () => ({ getApiKey: vi.fn(() => 'test-token') }))
vi.mock('@/api/hermes/download', () => ({ getDownloadUrl: vi.fn((path: string) => `/download?path=${path}`) }))
function emitSocket(event: string, payload: unknown) {
for (const cb of groupChatApiMock.handlers.get(event) || []) cb(payload)
}
const room: RoomInfo = {
id: 'room-1',
name: 'Test Room',
inviteCode: 'ROOM1',
}
function assistantMessage(overrides: Partial<ChatMessage>): ChatMessage {
return {
id: 'msg-1',
roomId: 'room-1',
senderId: 'agent-1',
senderName: 'bot',
content: '',
timestamp: 1,
role: 'assistant',
...overrides,
}
}
async function createJoinedStore(initialMessages: ChatMessage[] = []) {
groupChatApiMock.getRoomDetail.mockResolvedValue({
room,
messages: initialMessages,
agents: [],
members: [],
})
const { useGroupChatStore } = await import('@/stores/hermes/group-chat')
const store = useGroupChatStore()
store.connect()
await store.joinRoom('room-1')
groupChatApiMock.getRoomDetail.mockClear()
return store
}
describe('group chat store streaming merge', () => {
beforeEach(() => {
vi.useRealTimers()
setActivePinia(createPinia())
groupChatApiMock.handlers.clear()
for (const key of Object.keys(groupChatApiMock)) {
const value = (groupChatApiMock as any)[key]
if (value?.mockReset && key !== 'socket') value.mockReset()
}
groupChatApiMock.connectGroupChat.mockReturnValue(groupChatApiMock.socket)
groupChatApiMock.getSocket.mockReturnValue(groupChatApiMock.socket)
groupChatApiMock.getStoredUserId.mockReturnValue('user-1')
groupChatApiMock.getStoredUserName.mockReturnValue('tester')
groupChatApiMock.socket.on.mockClear()
groupChatApiMock.socket.emit.mockClear()
groupChatApiMock.socket.disconnect.mockClear()
})
it('preserves streamed reasoning when the final message supplies content only', async () => {
const store = await createJoinedStore()
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' }))
emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: 'thinking...' })
emitSocket('message', assistantMessage({ id: 'msg-1', content: '收到', reasoning: null, reasoning_content: null }))
expect(store.messages).toHaveLength(1)
expect(store.messages[0]).toMatchObject({
id: 'msg-1',
content: '收到',
reasoning: 'thinking...',
reasoning_content: 'thinking...',
isStreaming: false,
})
})
it('preserves streamed content when the final message payload is blank', async () => {
const store = await createJoinedStore()
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' }))
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: 'final' })
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' answer' })
emitSocket('message', assistantMessage({ id: 'msg-1', content: '', reasoning: 'thinking...' }))
expect(store.messages).toHaveLength(1)
expect(store.messages[0]).toMatchObject({
id: 'msg-1',
content: 'final answer',
reasoning: 'thinking...',
isStreaming: false,
})
})
it('ignores late content deltas for a completed message', async () => {
const store = await createJoinedStore()
emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' }))
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' })
expect(store.messages).toHaveLength(1)
expect(store.messages[0]).toMatchObject({
id: 'msg-1',
content: 'final answer',
reasoning: 'thinking...',
isStreaming: false,
})
})
it('ignores late reasoning deltas for a completed message', async () => {
const store = await createJoinedStore()
emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' }))
emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' })
expect(store.messages).toHaveLength(1)
expect(store.messages[0]).toMatchObject({
id: 'msg-1',
content: 'final answer',
reasoning: 'thinking...',
isStreaming: false,
})
})
it('ignores a late empty stream start for a completed message', async () => {
const store = await createJoinedStore()
emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' }))
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1', content: '', timestamp: 2 }))
expect(store.messages).toHaveLength(1)
expect(store.messages[0]).toMatchObject({
id: 'msg-1',
content: 'final answer',
reasoning: 'thinking...',
isStreaming: false,
})
})
it('ignores a late stream start for a completed empty tool-call message', async () => {
const store = await createJoinedStore()
const toolCalls = [{ id: 'tool-1', type: 'function', function: { name: 'lookup', arguments: '{}' } }]
emitSocket('message', assistantMessage({ id: 'msg-1', content: '', tool_calls: toolCalls }))
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1', content: '', timestamp: 2 }))
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' })
expect(store.messages).toHaveLength(1)
expect(store.messages[0]).toMatchObject({
id: 'msg-1',
content: '',
tool_calls: toolCalls,
isStreaming: false,
})
})
it('refetches room detail when a stream ends with reasoning but no final content', async () => {
vi.useFakeTimers()
const store = await createJoinedStore()
groupChatApiMock.getRoomDetail.mockResolvedValue({
room,
agents: [],
members: [],
messages: [assistantMessage({ id: 'msg-1', content: 'final from db', reasoning: 'thinking...' })],
})
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' }))
emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: 'thinking...' })
emitSocket('message_stream_end', { roomId: 'room-1', id: 'msg-1' })
await vi.runAllTimersAsync()
expect(groupChatApiMock.getRoomDetail).toHaveBeenCalledWith('room-1')
expect(store.messages[0]).toMatchObject({
id: 'msg-1',
content: 'final from db',
reasoning: 'thinking...',
isStreaming: false,
})
})
})
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const highlightJsMock = vi.hoisted(() => ({
getLanguage: vi.fn((lang?: string) => ['shell', 'xml', 'yaml', 'bash', 'json'].includes(lang || '')),
highlight: vi.fn((content: string, { language }: { language: string }) => ({
value: `<span class="mock-${language}">${content}</span>`,
})),
registerLanguage: vi.fn(),
}))
vi.mock('highlight.js', () => ({
default: highlightJsMock,
}))
import { normalizeHighlightLanguage, renderHighlightedCodeBlock } from '@/components/hermes/chat/highlight'
describe('highlight helper', () => {
beforeEach(() => {
vi.clearAllMocks()
highlightJsMock.getLanguage.mockImplementation((lang?: string) => ['shell', 'xml', 'yaml', 'bash', 'json'].includes(lang || ''))
highlightJsMock.highlight.mockImplementation((content: string, { language }: { language: string }) => ({
value: `<span class="mock-${language}">${content}</span>`,
}))
})
it.each([
['vue', 'xml'],
['yml', 'yaml'],
['sh', 'bash'],
['zsh', 'bash'],
['shellscript', 'bash'],
['shell', 'shell'],
])('normalizes %s to %s', (input, expected) => {
expect(normalizeHighlightLanguage(input)).toBe(expected)
})
it('uses a delegated copy attribute instead of inline javascript', () => {
const html = renderHighlightedCodeBlock('x', 'json', 'Copy')
expect(html).toContain('data-copy-code="true"')
expect(html).not.toContain('onclick=')
})
it('preserves shell-session highlighting instead of remapping shell fences to bash', () => {
const html = renderHighlightedCodeBlock('$ ls\nfoo.txt\n', 'shell', 'Copy')
expect(highlightJsMock.highlight).toHaveBeenCalledWith('$ ls\nfoo.txt\n', {
language: 'shell',
ignoreIllegals: true,
})
expect(html).toContain('class="code-lang">shell</span>')
})
it('skips highlighting for large known-language blocks when a render limit is set', () => {
const html = renderHighlightedCodeBlock('x'.repeat(5000), 'vue', 'Copy', {
maxHighlightLength: 2000,
})
expect(highlightJsMock.highlight).not.toHaveBeenCalled()
expect(html).toContain('class="code-lang">vue</span>')
})
it('falls back to escaped plaintext for unsupported fence labels', () => {
const html = renderHighlightedCodeBlock('<tag>', 'unknown', 'Copy')
expect(highlightJsMock.highlight).not.toHaveBeenCalled()
expect(html).toContain('&lt;tag&gt;')
expect(html).toContain('class="code-lang">unknown</span>')
})
it('falls back to escaped plaintext when direct highlighting throws', () => {
highlightJsMock.highlight.mockImplementationOnce(() => {
throw new Error('boom')
})
const html = renderHighlightedCodeBlock('<tag>', 'vue', 'Copy')
expect(html).toContain('&lt;tag&gt;')
expect(html).toContain('class="code-lang">vue</span>')
})
})
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { renderHighlightedCodeBlock } from '@/components/hermes/chat/highlight'
describe('highlight safety', () => {
it('escapes large unknown code content', () => {
const html = renderHighlightedCodeBlock('<img src=x onerror=alert(1)>'.repeat(100), 'unknown', 'Copy')
expect(html).toContain('&lt;img')
expect(html).not.toContain('<img')
})
it('does not emit executable HTML for known-language code', () => {
const html = renderHighlightedCodeBlock('<script>alert(1)</script>', 'xml', 'Copy')
expect(html).not.toContain('<script>')
expect(html).toContain('&lt;')
})
it('escapes the language label', () => {
const html = renderHighlightedCodeBlock('x'.repeat(5000), '<script>alert(1)</script>', 'Copy')
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
expect(html).not.toContain('<script>')
})
it('sanitizes the language class', () => {
const html = renderHighlightedCodeBlock('x'.repeat(5000), 'foo bar"><img', 'Copy')
expect(html).toContain('language-foo-bar---img')
})
it('escapes the copy label', () => {
const html = renderHighlightedCodeBlock('x', 'json', 'Copy <now>')
expect(html).toContain('Copy &lt;now&gt;')
expect(html).not.toContain('Copy <now>')
})
})
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it, beforeAll } from 'vitest'
import { readdirSync, readFileSync } from 'fs'
import { join, relative } from 'path'
import { changelog } from '@/data/changelog'
import { messages, supportedLocales } from '@/i18n/messages'
import en from '@/i18n/locales/en'
import { createI18n } from 'vue-i18n'
const SOURCE_ROOT = join(process.cwd(), 'packages/client/src')
const allMessages: Record<string, Record<string, unknown>> = { en }
function walkFiles(dir: string, files: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
walkFiles(path, files)
} else if (/\.(ts|vue)$/.test(entry.name) && !path.replace(/\\/g, '/').includes('/i18n/locales/')) {
files.push(path)
}
}
return files
}
function collectLiteralTranslationKeys(): string[] {
const keys = new Set<string>()
const translationCall = /(?:\b|\$)t\(\s*['"]([^'"]+)['"]/g
for (const file of walkFiles(SOURCE_ROOT)) {
const source = readFileSync(file, 'utf8')
for (const match of source.matchAll(translationCall)) {
keys.add(match[1])
}
}
for (const entry of changelog) {
for (const change of entry.changes) {
keys.add(change)
}
}
return [...keys].sort()
}
function getPath(messages: Record<string, unknown>, key: string): unknown {
let current: unknown = messages
for (const part of key.split('.')) {
if (!current || typeof current !== 'object' || !(part in current)) return undefined
current = (current as Record<string, unknown>)[part]
}
return current
}
function hasPath(messages: Record<string, unknown>, key: string): boolean {
return typeof getPath(messages, key) !== 'undefined'
}
const SKILLS_USAGE_LOCALIZED_KEYS = [
'sidebar.skillsUsage',
'skillsUsage.title',
'skillsUsage.subtitle',
'skillsUsage.refresh',
'skillsUsage.periodSelector',
'skillsUsage.periodLabel',
'skillsUsage.summary',
'skillsUsage.totalActions',
'skillsUsage.loads',
'skillsUsage.edits',
'skillsUsage.distinctSkills',
'skillsUsage.topSkills',
'skillsUsage.dailyTrend',
'skillsUsage.periodSummary',
'skillsUsage.skill',
'skillsUsage.share',
'skillsUsage.lastUsed',
'skillsUsage.noData',
'skillsUsage.loadFailed',
'skillsUsage.otherSkills',
]
const SKILLS_USAGE_COMPACT_LABEL_LIMITS: Record<string, number> = {
'skillsUsage.totalActions': 12,
'skillsUsage.loads': 10,
'skillsUsage.edits': 10,
'skillsUsage.distinctSkills': 12,
'skillsUsage.topSkills': 16,
'skillsUsage.dailyTrend': 16,
'skillsUsage.skill': 10,
'skillsUsage.share': 10,
'skillsUsage.lastUsed': 12,
'skillsUsage.otherSkills': 16,
}
function labelLength(value: unknown): number {
return typeof value === 'string' ? Array.from(value.replace(/\{[^}]+\}/g, '')).length : Infinity
}
describe('i18n locale coverage', () => {
const ALLOWED_MISSING_KEYS = new Set([
'changelog.new_0_5_4_7',
'chat.sessionNotFound',
])
beforeAll(() => {
for (const l of supportedLocales) {
if (l !== 'en' && messages[l]) {
allMessages[l] = messages[l]
}
}
})
it('defines every statically referenced translation key in the English source locale', () => {
const missing = collectLiteralTranslationKeys()
.filter((key) => !hasPath(en, key))
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
expect(missing).toEqual([])
})
it('defines every statically referenced translation key in effective runtime messages', () => {
const requiredKeys = collectLiteralTranslationKeys()
const missing = Object.entries(allMessages).flatMap(([locale, localeMessages]) =>
requiredKeys
.filter((key) => !hasPath(localeMessages, key))
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
.map((key) => `${locale}: ${key}`),
)
expect(missing).toEqual([])
})
it('localizes Skills Usage page copy in every non-English locale instead of falling back to English', () => {
const englishMessages = messages.en
const untranslated = Object.entries(messages).flatMap(([locale, localeMessages]) => {
if (locale === 'en') return []
return SKILLS_USAGE_LOCALIZED_KEYS.flatMap((key) => {
const localeValue = getPath(localeMessages, key)
if (typeof localeValue === 'undefined') return [`${locale}: ${key} missing`]
return localeValue === getPath(englishMessages, key) ? [`${locale}: ${key}`] : []
})
})
expect(untranslated).toEqual([])
})
it('keeps Skills Usage summary and table labels compact across locales', () => {
const oversized = Object.entries(messages).flatMap(([locale, localeMessages]) =>
Object.entries(SKILLS_USAGE_COMPACT_LABEL_LIMITS).flatMap(([key, maxLength]) => {
const localeValue = getPath(localeMessages, key)
return labelLength(localeValue) > maxLength
? [`${locale}: ${key} (${labelLength(localeValue)} > ${maxLength})`]
: []
}),
)
expect(oversized).toEqual([])
})
it('keeps the coverage scanner rooted in client source files', () => {
expect(relative(process.cwd(), SOURCE_ROOT)).toBe(join('packages', 'client', 'src'))
})
})
+135
View File
@@ -0,0 +1,135 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
const mockMessage = vi.hoisted(() => ({
warning: vi.fn(),
success: vi.fn(),
error: vi.fn(),
}))
const mockSettingsStore = vi.hoisted(() => ({
platforms: {} as Record<string, any>,
fetchSettings: vi.fn(async () => {
mockSettingsStore.platforms = {
telegram: { token: 'telegram-token' },
discord: { token: 'discord-token' },
slack: { token: 'slack-token' },
whatsapp: { enabled: true },
matrix: { token: 'matrix-token' },
weixin: { token: 'weixin-token' },
wecom: { extra: { bot_id: 'wecom-bot' } },
feishu: { extra: { app_id: 'feishu-app' } },
dingtalk: { extra: { client_id: 'dingtalk-client' } },
qqbot: { extra: { app_id: 'qq-app', client_secret: 'qq-secret' } },
}
}),
}))
const mockJobsStore = vi.hoisted(() => ({
createJob: vi.fn(),
updateJob: vi.fn(),
}))
vi.mock('@/stores/hermes/settings', () => ({
useSettingsStore: () => mockSettingsStore,
}))
vi.mock('@/stores/hermes/jobs', () => ({
useJobsStore: () => mockJobsStore,
}))
vi.mock('@/api/hermes/jobs', async () => {
const actual = await vi.importActual<any>('@/api/hermes/jobs')
return {
...actual,
getJob: vi.fn(),
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('naive-ui', () => ({
NModal: defineComponent({
template: '<div class="n-modal-stub"><slot /><slot name="footer" /></div>',
}),
NForm: defineComponent({ template: '<form><slot /></form>' }),
NFormItem: defineComponent({ template: '<div><slot /></div>' }),
NInput: defineComponent({
props: { value: { type: String, required: false } },
emits: ['update:value'],
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
}),
NInputNumber: defineComponent({
props: { value: { required: false } },
emits: ['update:value'],
template: '<input class="n-input-number-stub" :value="value" type="number" @input="$emit(\'update:value\', Number($event.target.value))" />',
}),
NSelect: defineComponent({
props: { value: { required: false }, options: { type: Array, default: () => [] } },
emits: ['update:value'],
template: '<select class="n-select-stub"><option v-for="option in options" :key="option.value" :value="option.value" :disabled="option.disabled">{{ option.label }}</option></select>',
}),
NButton: defineComponent({
emits: ['click'],
template: '<button class="n-button-stub" @click.prevent="$emit(\'click\')"><slot /></button>',
}),
useMessage: () => mockMessage,
}))
import JobFormModal from '@/components/hermes/jobs/JobFormModal.vue'
describe('JobFormModal deliver targets', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingsStore.platforms = {}
})
it('loads platform settings when the store has not been hydrated', async () => {
mount(JobFormModal, {
props: { jobId: null },
})
await flushPromises()
expect(mockSettingsStore.fetchSettings).toHaveBeenCalledOnce()
})
it('shows every supported platform channel in deliver target options', async () => {
mockSettingsStore.platforms = {
telegram: { token: 'telegram-token' },
whatsapp: { enabled: false },
qqbot: { extra: { app_id: 'qq-app', client_secret: 'qq-secret' } },
}
const wrapper = mount(JobFormModal, {
props: { jobId: null },
})
await flushPromises()
expect(mockSettingsStore.fetchSettings).not.toHaveBeenCalled()
const labels = wrapper.findAll('.n-select-stub')[1].text()
expect(labels).toContain('Telegram')
expect(labels).toContain('Discord')
expect(labels).toContain('Slack')
expect(labels).toContain('WhatsApp')
expect(labels).toContain('Matrix')
expect(labels).toContain('WeChat')
expect(labels).toContain('WeCom')
expect(labels).toContain('Feishu')
expect(labels).toContain('DingTalk')
expect(labels).toContain('QQBot')
const options = wrapper.findAll('.n-select-stub')[1].findAll('option')
const optionByValue = Object.fromEntries(options.map(option => [option.attributes('value'), option]))
expect(optionByValue.telegram.attributes('disabled')).toBeUndefined()
expect(optionByValue.qqbot.attributes('disabled')).toBeUndefined()
expect(optionByValue.discord.attributes('disabled')).toBe('')
expect(optionByValue.whatsapp.attributes('disabled')).toBe('')
})
})
+138
View File
@@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { name: 'hermes.jobs' } },
replace: vi.fn(),
},
}))
import {
buildJobUpdateRequest,
scheduleToDisplayText,
scheduleToEditableInput,
updateJob,
} from '../../packages/client/src/api/hermes/jobs'
import { listCronRuns } from '../../packages/client/src/api/hermes/cron-history'
import type { Job } from '../../packages/client/src/api/hermes/jobs'
function makeJob(overrides: Partial<Job> = {}): Job {
return {
job_id: 'job-1',
id: 'job-1',
name: 'artifact cleanup',
prompt: 'short prompt',
skills: [],
skill: null,
model: null,
provider: null,
base_url: null,
script: null,
schedule: { kind: 'interval', minutes: 7200, display: 'every 7200m' },
schedule_display: 'every 7200m',
repeat: { times: null, completed: 0 },
enabled: true,
state: 'scheduled',
paused_at: null,
paused_reason: null,
created_at: '2026-04-30T00:00:00Z',
next_run_at: null,
last_run_at: null,
last_status: null,
last_error: null,
deliver: 'origin',
origin: null,
last_delivery_error: null,
...overrides,
}
}
describe('Hermes jobs edit payloads', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('uses display text for interval schedules without manufacturing expr', () => {
const schedule = { kind: 'interval' as const, minutes: 7200, display: 'every 7200m' }
expect(scheduleToEditableInput(schedule, '')).toBe('every 7200m')
expect(scheduleToDisplayText(schedule, '—')).toBe('every 7200m')
})
it('keeps cron expr as the editable schedule string', () => {
const schedule = { kind: 'cron' as const, expr: '0 9 * * 1', display: '0 9 * * 1' }
expect(scheduleToEditableInput(schedule, '')).toBe('0 9 * * 1')
expect(scheduleToDisplayText(schedule, '—')).toBe('0 9 * * 1')
})
it('omits unchanged long prompts from name-only updates', () => {
const prompt = 'x'.repeat(9484)
const original = makeJob({ prompt })
const payload = buildJobUpdateRequest(original, {
name: 'artifact cleanup renamed',
schedule: 'every 7200m',
prompt,
deliver: 'origin',
repeat_times: null,
})
expect(payload).toEqual({ name: 'artifact cleanup renamed' })
expect(payload).not.toHaveProperty('prompt')
expect(payload).not.toHaveProperty('schedule')
})
it('sends changed interval schedules as raw strings', () => {
const original = makeJob()
const payload = buildJobUpdateRequest(original, {
name: original.name,
schedule: 'every 14400m',
prompt: original.prompt,
deliver: 'origin',
repeat_times: null,
})
expect(payload).toEqual({ schedule: 'every 14400m' })
})
it('does not send a PATCH body with structured schedule objects', async () => {
const returnedJob = makeJob({ name: 'artifact cleanup renamed' })
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ job: returnedJob }),
})
await updateJob('job-1', { name: 'artifact cleanup renamed', schedule: 'every 14400m' })
expect(mockFetch).toHaveBeenCalledOnce()
const [, options] = mockFetch.mock.calls[0]
expect(JSON.parse(options.body as string)).toEqual({
name: 'artifact cleanup renamed',
schedule: 'every 14400m',
})
})
it('sends active profile header when loading job run history', async () => {
localStorage.setItem('hermes_active_profile_name', 'research')
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ runs: [] }),
})
await listCronRuns('job-1')
expect(mockFetch).toHaveBeenCalledOnce()
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('/api/cron-history?jobId=job-1')
expect(options.headers['X-Hermes-Profile']).toBe('research')
})
})
+173
View File
@@ -0,0 +1,173 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockRequest = vi.hoisted(() => vi.fn())
const mockGetApiKey = vi.hoisted(() => vi.fn(() => ''))
const mockGetBaseUrlValue = vi.hoisted(() => vi.fn(() => ''))
vi.mock('../../packages/client/src/api/client', () => ({
request: mockRequest,
getApiKey: mockGetApiKey,
getBaseUrlValue: mockGetBaseUrlValue,
}))
import {
listBoards,
createBoard,
archiveBoard,
getCapabilities,
listTasks,
getTask,
createTask,
completeTasks,
blockTask,
unblockTasks,
assignTask,
addComment,
linkTasks,
unlinkTasks,
bulkUpdateTasks,
getTaskLog,
getDiagnostics,
reclaimTask,
reassignTask,
specifyTask,
dispatch,
getStats,
getAssignees,
buildKanbanEventsWebSocketUrl,
} from '../../packages/client/src/api/hermes/kanban'
describe('Kanban API', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockGetApiKey.mockReturnValue('')
mockGetBaseUrlValue.mockReturnValue('')
})
it('builds board-scoped kanban event websocket URLs with auth token', () => {
mockGetBaseUrlValue.mockReturnValue('https://wui.example.test')
mockGetApiKey.mockReturnValue('token value')
localStorage.setItem('hermes_active_profile_name', 'research')
expect(buildKanbanEventsWebSocketUrl({ board: 'project-a' })).toBe('wss://wui.example.test/api/hermes/kanban/events?board=project-a&token=token+value&profile=research')
expect(buildKanbanEventsWebSocketUrl()).toBe('wss://wui.example.test/api/hermes/kanban/events?board=default&token=token+value&profile=research')
})
it('serializes board, list filters, and archived inclusion into query params', async () => {
mockRequest.mockResolvedValue({ tasks: [{ id: 'task-1' }] })
const result = await listTasks({ board: 'default', status: 'blocked', assignee: 'alice', tenant: 'ops', includeArchived: true })
expect(mockRequest).toHaveBeenCalledWith('/api/hermes/kanban?board=default&status=blocked&assignee=alice&tenant=ops&includeArchived=true')
expect(result).toEqual([{ id: 'task-1' }])
})
it('keeps default board explicit when no board is supplied', async () => {
mockRequest
.mockResolvedValueOnce({ tasks: [] })
.mockResolvedValueOnce({ stats: { total: 0, by_status: {}, by_assignee: {} } })
.mockResolvedValueOnce({ assignees: [] })
.mockResolvedValueOnce({ task: { id: 'task-1' }, comments: [], events: [], runs: [] })
await listTasks()
await getStats()
await getAssignees()
await getTask('task-1')
expect(mockRequest.mock.calls.map(call => call[0])).toEqual([
'/api/hermes/kanban?board=default',
'/api/hermes/kanban/stats?board=default',
'/api/hermes/kanban/assignees?board=default',
'/api/hermes/kanban/task-1?board=default',
])
})
it('posts create and action payloads with explicit board in the URL', async () => {
mockRequest
.mockResolvedValueOnce({ task: { id: 'task-1' } })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true })
expect(await createTask({ title: 'Ship', assignee: 'alice', priority: 3 }, { board: 'project-a' })).toEqual({ id: 'task-1' })
await completeTasks(['task-1'], 'done', { board: 'project-a' })
await blockTask('task-1', 'waiting', { board: 'project-a' })
await unblockTasks(['task-1'], { board: 'project-a' })
await assignTask('task-1', 'bob', { board: 'project-a' })
expect(mockRequest.mock.calls).toEqual([
['/api/hermes/kanban?board=project-a', { method: 'POST', body: JSON.stringify({ title: 'Ship', assignee: 'alice', priority: 3 }) }],
['/api/hermes/kanban/complete?board=project-a', { method: 'POST', body: JSON.stringify({ task_ids: ['task-1'], summary: 'done' }) }],
['/api/hermes/kanban/task-1/block?board=project-a', { method: 'POST', body: JSON.stringify({ reason: 'waiting' }) }],
['/api/hermes/kanban/unblock?board=project-a', { method: 'POST', body: JSON.stringify({ task_ids: ['task-1'] }) }],
['/api/hermes/kanban/task-1/assign?board=project-a', { method: 'POST', body: JSON.stringify({ profile: 'bob' }) }],
])
})
it('lists and manages boards through explicit board endpoints', async () => {
mockRequest
.mockResolvedValueOnce({ boards: [{ slug: 'default' }] })
.mockResolvedValueOnce({ board: { slug: 'project-a' } })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ capabilities: { source: 'hermes-cli', supports: { boardsList: true }, missing: [] } })
.mockResolvedValueOnce({ stats: { total: 3, by_status: {}, by_assignee: {} } })
.mockResolvedValueOnce({ assignees: [{ name: 'alice', on_disk: true, counts: { todo: 1 } }] })
await expect(listBoards({ includeArchived: true })).resolves.toEqual([{ slug: 'default' }])
await expect(createBoard({ slug: 'project-a', name: 'Project A' })).resolves.toEqual({ slug: 'project-a' })
await expect(archiveBoard('project-a')).resolves.toEqual({ ok: true })
await expect(getCapabilities()).resolves.toEqual({ source: 'hermes-cli', supports: { boardsList: true }, missing: [] })
await expect(getStats({ board: 'project-a' })).resolves.toEqual({ total: 3, by_status: {}, by_assignee: {} })
await expect(getAssignees({ board: 'project-a' })).resolves.toEqual([{ name: 'alice', on_disk: true, counts: { todo: 1 } }])
expect(mockRequest.mock.calls).toEqual([
['/api/hermes/kanban/boards?includeArchived=true'],
['/api/hermes/kanban/boards', { method: 'POST', body: JSON.stringify({ slug: 'project-a', name: 'Project A' }) }],
['/api/hermes/kanban/boards/project-a', { method: 'DELETE' }],
['/api/hermes/kanban/capabilities'],
['/api/hermes/kanban/stats?board=project-a'],
['/api/hermes/kanban/assignees?board=project-a'],
])
})
it('calls parity-gap APIs with explicit board query params', async () => {
mockRequest
.mockResolvedValueOnce({ ok: true, output: 'commented' })
.mockResolvedValueOnce({ ok: true, output: 'linked' })
.mockResolvedValueOnce({ ok: true, output: 'unlinked' })
.mockResolvedValueOnce({ results: [{ id: 'task-1', ok: true }] })
.mockResolvedValueOnce({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
.mockResolvedValueOnce({ diagnostics: [{ task_id: 'task-1' }] })
.mockResolvedValueOnce({ ok: true, output: 'reclaimed' })
.mockResolvedValueOnce({ ok: true, output: 'reassigned' })
.mockResolvedValueOnce({ results: [{ task_id: 'task-1' }] })
.mockResolvedValueOnce({ result: { spawned: 1 } })
await addComment('task-1', { body: 'needs review', author: 'han' }, { board: 'default' })
await linkTasks({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
await unlinkTasks({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
await expect(bulkUpdateTasks({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }, { board: 'project-a' })).resolves.toEqual({ results: [{ id: 'task-1', ok: true }] })
await expect(getTaskLog('task-1', { board: 'default', tail: 4000 })).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
await expect(getDiagnostics({ board: 'default', task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1' }])
await reclaimTask('task-1', { board: 'project-a', reason: 'stale' })
await reassignTask('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
await expect(specifyTask('task-1', { board: 'default', author: 'han' })).resolves.toEqual([{ task_id: 'task-1' }])
await expect(dispatch({ board: 'default', dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
expect(mockRequest.mock.calls).toEqual([
['/api/hermes/kanban/task-1/comments?board=default', { method: 'POST', body: JSON.stringify({ body: 'needs review', author: 'han' }) }],
['/api/hermes/kanban/links?board=project-a', { method: 'POST', body: JSON.stringify({ parent_id: 'task-1', child_id: 'task-2' }) }],
['/api/hermes/kanban/links?board=project-a&parent_id=task-1&child_id=task-2', { method: 'DELETE' }],
['/api/hermes/kanban/tasks/bulk?board=project-a', { method: 'POST', body: JSON.stringify({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }) }],
['/api/hermes/kanban/task-1/log?board=default&tail=4000'],
['/api/hermes/kanban/diagnostics?board=default&task=task-1&severity=warning'],
['/api/hermes/kanban/task-1/reclaim?board=project-a', { method: 'POST', body: JSON.stringify({ reason: 'stale' }) }],
['/api/hermes/kanban/task-1/reassign?board=project-a', { method: 'POST', body: JSON.stringify({ profile: 'bob', reclaim: true, reason: 'handoff' }) }],
['/api/hermes/kanban/task-1/specify?board=default', { method: 'POST', body: JSON.stringify({ author: 'han' }) }],
['/api/hermes/kanban/dispatch?board=default', { method: 'POST', body: JSON.stringify({ dryRun: true, max: 2, failureLimit: 3 }) }],
])
})
})
+97
View File
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
const mockCreateTask = vi.hoisted(() => vi.fn())
const mockMessage = vi.hoisted(() => ({
warning: vi.fn(),
success: vi.fn(),
error: vi.fn(),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/stores/hermes/kanban', () => ({
useKanbanStore: () => ({
assignees: [{ name: 'alice', counts: { todo: 1 } }],
createTask: mockCreateTask,
}),
}))
vi.mock('naive-ui', () => ({
NModal: defineComponent({
emits: ['close'],
template: '<div class="n-modal-stub"><slot /><slot name="action" /></div>',
}),
NForm: defineComponent({ template: '<form><slot /></form>' }),
NFormItem: defineComponent({ template: '<div><slot /></div>' }),
NInput: defineComponent({
props: { value: { type: String, required: false } },
emits: ['update:value'],
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
}),
NSelect: defineComponent({
props: { value: { required: false }, options: { type: Array, default: () => [] } },
emits: ['update:value'],
template: '<select class="n-select-stub" @change="$emit(\'update:value\', $event.target.value === \'\' ? null : (/^\\d+$/.test($event.target.value) ? Number($event.target.value) : $event.target.value))"><option value=""></option><option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option></select>',
}),
NButton: defineComponent({
emits: ['click'],
template: '<button class="n-button-stub" @click.prevent="$emit(\'click\')"><slot /></button>',
}),
useMessage: () => mockMessage,
}))
import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue'
describe('KanbanCreateForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('validates required title before submit', async () => {
const wrapper = mount(KanbanCreateForm)
await wrapper.findAll('.n-button-stub')[1].trigger('click')
expect(mockMessage.warning).toHaveBeenCalledWith('kanban.form.titleRequired')
expect(mockCreateTask).not.toHaveBeenCalled()
})
it('submits trimmed values and emits created/close', async () => {
mockCreateTask.mockResolvedValue({ id: 'task-1' })
const wrapper = mount(KanbanCreateForm)
const inputs = wrapper.findAll('.n-input-stub')
await inputs[0].setValue(' Ship kanban ')
await inputs[1].setValue(' write tests ')
const selects = wrapper.findAll('.n-select-stub')
await selects[0].setValue('alice')
await selects[1].setValue('3')
await wrapper.findAll('.n-button-stub')[1].trigger('click')
await flushPromises()
expect(mockCreateTask).toHaveBeenCalledWith({
title: 'Ship kanban',
body: 'write tests',
assignee: 'alice',
priority: 3,
})
expect(mockMessage.success).toHaveBeenCalledWith('kanban.message.taskCreated')
expect(wrapper.emitted('created')).toBeTruthy()
expect(wrapper.emitted('close')).toBeTruthy()
})
it('uses compact profile names for assignee options', () => {
const wrapper = mount(KanbanCreateForm)
expect(wrapper.text()).toContain('alice')
expect(wrapper.text()).not.toContain('default')
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
})
})
+361
View File
@@ -0,0 +1,361 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mockKanbanApi = vi.hoisted(() => ({
listBoards: vi.fn(),
createBoard: vi.fn(),
archiveBoard: vi.fn(),
getCapabilities: vi.fn(),
listTasks: vi.fn(),
getStats: vi.fn(),
getAssignees: vi.fn(),
createTask: vi.fn(),
completeTasks: vi.fn(),
blockTask: vi.fn(),
unblockTasks: vi.fn(),
assignTask: vi.fn(),
addComment: vi.fn(),
linkTasks: vi.fn(),
unlinkTasks: vi.fn(),
bulkUpdateTasks: vi.fn(),
getTaskLog: vi.fn(),
getDiagnostics: vi.fn(),
reclaimTask: vi.fn(),
reassignTask: vi.fn(),
specifyTask: vi.fn(),
dispatch: vi.fn(),
openKanbanEventStream: vi.fn(),
}))
vi.mock('@/api/hermes/kanban', () => mockKanbanApi)
import { KANBAN_SELECTED_BOARD_STORAGE_KEY, normalizeBoardSlug, useKanbanStore } from '@/stores/hermes/kanban'
describe('Kanban store', () => {
it('normalizes board slugs with canonical underscore, uppercase, and length rules', () => {
const sixtyFour = 'a'.repeat(64)
expect(normalizeBoardSlug(' Team_Alpha ')).toBe('team_alpha')
expect(normalizeBoardSlug(sixtyFour)).toBe(sixtyFour)
expect(normalizeBoardSlug('default')).toBe('default')
expect(normalizeBoardSlug('bad/slug')).toBe('default')
expect(normalizeBoardSlug('bad.slug')).toBe('default')
expect(normalizeBoardSlug('bad slug')).toBe('default')
})
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
vi.clearAllMocks()
mockKanbanApi.listBoards.mockResolvedValue([
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
{ slug: 'project-a', name: 'Project A', archived: false, counts: { todo: 1 }, total: 1 },
])
mockKanbanApi.getCapabilities.mockResolvedValue({ source: 'hermes-cli', supports: { boardsList: true }, missing: [] })
mockKanbanApi.openKanbanEventStream.mockReturnValue({ close: vi.fn(), onmessage: null, onclose: null, onerror: null })
})
it('persists selected board, including default, and falls back to default for missing boards', async () => {
const store = useKanbanStore()
await store.fetchBoards()
expect(store.setSelectedBoard('project-a')).toBe('project-a')
expect(window.localStorage.getItem(KANBAN_SELECTED_BOARD_STORAGE_KEY)).toBe('project-a')
expect(store.setSelectedBoard('default')).toBe('default')
expect(window.localStorage.getItem(KANBAN_SELECTED_BOARD_STORAGE_KEY)).toBe('default')
const recovered = store.recoverSelectedBoard('missing-board')
expect(recovered).toEqual({ board: 'default', recovered: true })
expect(store.selectedBoard).toBe('default')
expect(store.boardWarning).toContain('missing-board')
})
it('fetchTasks uses active filters and selected board while updating loading', async () => {
mockKanbanApi.listTasks.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve([{ id: 'task-1', status: 'todo' }]), 0))
)
const store = useKanbanStore()
store.setSelectedBoard('project-a')
store.setFilter('status', 'blocked')
store.setFilter('assignee', 'alice')
const promise = store.fetchTasks()
expect(store.loading).toBe(true)
await promise
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: 'blocked', assignee: 'alice', includeArchived: true })
expect(store.tasks).toEqual([{ id: 'task-1', status: 'todo' }])
expect(store.loading).toBe(false)
})
it('create and status actions pass selected board, update local task state, and refresh board counts', async () => {
mockKanbanApi.createTask.mockResolvedValue({ id: 'task-2', status: 'todo', assignee: null })
mockKanbanApi.completeTasks.mockResolvedValue({ ok: true })
mockKanbanApi.blockTask.mockResolvedValue({ ok: true })
mockKanbanApi.unblockTasks.mockResolvedValue({ ok: true })
mockKanbanApi.assignTask.mockResolvedValue({ ok: true })
mockKanbanApi.getStats.mockResolvedValue({ total: 2, by_status: { done: 1 }, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'bob', on_disk: true, counts: { ready: 1 } }])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
store.tasks = [{ id: 'task-1', status: 'running', assignee: null }] as any
await store.createTask({ title: 'Ship' })
await store.completeTasks(['task-1'], 'done')
await store.blockTask('task-2', 'waiting')
await store.unblockTasks(['task-2'])
await store.assignTask('task-2', 'bob')
expect(mockKanbanApi.createTask).toHaveBeenCalledWith({ title: 'Ship' }, { board: 'project-a' })
expect(mockKanbanApi.completeTasks).toHaveBeenCalledWith(['task-1'], 'done', { board: 'project-a' })
expect(mockKanbanApi.blockTask).toHaveBeenCalledWith('task-2', 'waiting', { board: 'project-a' })
expect(mockKanbanApi.unblockTasks).toHaveBeenCalledWith(['task-2'], { board: 'project-a' })
expect(mockKanbanApi.assignTask).toHaveBeenCalledWith('task-2', 'bob', { board: 'project-a' })
expect(mockKanbanApi.listBoards).toHaveBeenCalledTimes(4)
expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' })
expect(store.tasks[0]).toMatchObject({ id: 'task-2', status: 'ready', assignee: 'bob' })
expect(store.tasks[1]).toMatchObject({ id: 'task-1', status: 'done' })
})
it('uses capability metadata before calling parity APIs', async () => {
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: { commentsWrite: true, dispatch: false },
missing: ['dispatch'],
})
mockKanbanApi.addComment.mockResolvedValue({ ok: true })
const store = useKanbanStore()
store.setSelectedBoard('project-a')
await store.fetchCapabilities()
expect(store.isCapabilitySupported('commentsWrite')).toBe(true)
expect(store.isCapabilitySupported('dispatch')).toBe(false)
await store.addComment('task-1', 'needs review', 'han')
await expect(store.dispatch({ dryRun: true })).rejects.toThrow('dispatch')
expect(mockKanbanApi.addComment).toHaveBeenCalledWith('task-1', { body: 'needs review', author: 'han' }, { board: 'project-a' })
expect(mockKanbanApi.dispatch).not.toHaveBeenCalled()
})
it('passes selected board to link and partial bulk parity actions', async () => {
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: { links: true, bulk: false },
missing: ['bulk'],
capabilities: [
{ key: 'links', status: 'supported', requiresBoard: true },
{ key: 'bulk', status: 'partial', requiresBoard: true },
],
})
mockKanbanApi.linkTasks.mockResolvedValue({ ok: true })
mockKanbanApi.unlinkTasks.mockResolvedValue({ ok: true })
mockKanbanApi.bulkUpdateTasks.mockResolvedValue({ results: [{ id: 'task-1', ok: true }] })
mockKanbanApi.listTasks.mockResolvedValue([])
mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
await store.fetchCapabilities()
await store.linkTasks('task-1', 'task-2')
await store.unlinkTasks('task-1', 'task-2')
await expect(store.bulkUpdateTasks({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' })).resolves.toEqual({ results: [{ id: 'task-1', ok: true }] })
expect(mockKanbanApi.linkTasks).toHaveBeenCalledWith({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
expect(mockKanbanApi.unlinkTasks).toHaveBeenCalledWith({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
expect(mockKanbanApi.bulkUpdateTasks).toHaveBeenCalledWith({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }, { board: 'project-a' })
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true })
})
it('opens board-scoped event streams, refreshes on events, and reconnects on board switch', async () => {
vi.useFakeTimers()
const socketA = { close: vi.fn(), onmessage: null as ((event: { data: string }) => void) | null, onclose: null, onerror: null }
const socketB = { close: vi.fn(), onmessage: null as ((event: { data: string }) => void) | null, onclose: null, onerror: null }
mockKanbanApi.openKanbanEventStream
.mockReturnValueOnce(socketA)
.mockReturnValueOnce(socketB)
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: {},
missing: [],
capabilities: [{ key: 'events', status: 'partial', requiresBoard: true }],
})
mockKanbanApi.listTasks.mockResolvedValue([])
mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
await store.fetchCapabilities()
expect(store.startEventStream()).toBe(true)
expect(mockKanbanApi.openKanbanEventStream).toHaveBeenCalledWith({ board: 'project-a' })
socketA.onmessage?.({ data: JSON.stringify({ type: 'event', line: 'changed' }) })
await vi.advanceTimersByTimeAsync(100)
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true })
expect(mockKanbanApi.getStats).toHaveBeenCalledWith({ board: 'project-a' })
expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' })
store.setSelectedBoard('default')
expect(socketA.close).toHaveBeenCalled()
expect(mockKanbanApi.openKanbanEventStream).toHaveBeenLastCalledWith({ board: 'default' })
store.stopEventStream()
expect(socketB.close).toHaveBeenCalled()
vi.useRealTimers()
})
it('passes selected board to parity actions and refreshes affected board state', async () => {
mockKanbanApi.getCapabilities.mockResolvedValue({
source: 'hermes-cli',
supports: { taskLog: true, diagnostics: true, reclaim: true, reassign: true, specify: true, dispatch: true },
missing: [],
})
mockKanbanApi.getTaskLog.mockResolvedValue({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
mockKanbanApi.getDiagnostics.mockResolvedValue([{ task_id: 'task-1' }])
mockKanbanApi.reclaimTask.mockResolvedValue({ ok: true })
mockKanbanApi.reassignTask.mockResolvedValue({ ok: true })
mockKanbanApi.specifyTask.mockResolvedValue([{ task_id: 'task-1' }])
mockKanbanApi.dispatch.mockResolvedValue({ spawned: 1 })
mockKanbanApi.getStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'bob', on_disk: true, counts: {} }])
mockKanbanApi.listTasks.mockResolvedValue([{ id: 'task-1', assignee: 'bob' }])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
store.tasks = [{ id: 'task-1', status: 'running', assignee: 'alice' }] as any
await store.fetchCapabilities()
await expect(store.getTaskLog('task-1', 4000)).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
await expect(store.getDiagnostics({ task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1' }])
await store.reclaimTask('task-1', 'stale')
await store.reassignTask('task-1', 'bob', { reclaim: true, reason: 'handoff' })
await expect(store.specifyTask('task-1', 'han')).resolves.toEqual([{ task_id: 'task-1' }])
await expect(store.dispatch({ dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
expect(mockKanbanApi.getTaskLog).toHaveBeenCalledWith('task-1', { board: 'project-a', tail: 4000 })
expect(mockKanbanApi.getDiagnostics).toHaveBeenCalledWith({ board: 'project-a', task: 'task-1', severity: 'warning' })
expect(mockKanbanApi.reclaimTask).toHaveBeenCalledWith('task-1', { board: 'project-a', reason: 'stale' })
expect(mockKanbanApi.reassignTask).toHaveBeenCalledWith('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
expect(mockKanbanApi.specifyTask).toHaveBeenCalledWith('task-1', { board: 'project-a', author: 'han' })
expect(mockKanbanApi.dispatch).toHaveBeenCalledWith({ board: 'project-a', dryRun: true, max: 2, failureLimit: 3 })
expect(store.tasks[0]).toMatchObject({ id: 'task-1', assignee: 'bob' })
})
it('creates and archives boards without relying on CLI current board', async () => {
mockKanbanApi.listBoards.mockResolvedValue([
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
{ slug: 'new-board', name: 'New Board', archived: false, counts: {}, total: 0 },
])
mockKanbanApi.createBoard.mockResolvedValue({ slug: 'new-board', name: 'New Board', archived: false, counts: {}, total: 0 })
mockKanbanApi.archiveBoard.mockResolvedValue({ ok: true })
mockKanbanApi.listTasks.mockResolvedValue([])
mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([])
const store = useKanbanStore()
await store.createBoard({ slug: 'new-board', name: 'New Board' })
expect(mockKanbanApi.createBoard).toHaveBeenCalledWith({ slug: 'new-board', name: 'New Board' })
expect(store.selectedBoard).toBe('new-board')
await store.archiveSelectedBoard()
expect(mockKanbanApi.archiveBoard).toHaveBeenCalledWith('new-board')
expect(store.selectedBoard).toBe('default')
})
it('refreshAll loads boards, tasks, stats, and assignees for the same board', async () => {
mockKanbanApi.listTasks.mockResolvedValue([{ id: 'task-1' }])
mockKanbanApi.getStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} })
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'alice', on_disk: true, counts: { todo: 1 } }])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
await store.refreshAll()
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true })
expect(mockKanbanApi.getStats).toHaveBeenCalledWith({ board: 'project-a' })
expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' })
expect(mockKanbanApi.listBoards).toHaveBeenCalledWith({ includeArchived: false })
expect(store.tasks).toEqual([{ id: 'task-1' }])
expect(store.stats).toEqual({ total: 1, by_status: {}, by_assignee: {} })
expect(store.assignees).toEqual([{ name: 'alice', on_disk: true, counts: { todo: 1 } }])
})
it('ignores stale board-list responses after a newer request', async () => {
let resolveSlowBoards: (value: unknown) => void = () => {}
mockKanbanApi.listBoards
.mockImplementationOnce(() => new Promise(resolve => { resolveSlowBoards = resolve }))
.mockResolvedValueOnce([
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
{ slug: 'project-a', name: 'Project A', archived: false, counts: { todo: 2 }, total: 2 },
])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
const slowFetch = store.fetchBoards()
await store.fetchBoards()
resolveSlowBoards([{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 }])
await slowFetch
expect(store.selectedBoard).toBe('project-a')
expect(store.activeBoards).toEqual(expect.arrayContaining([
expect.objectContaining({ slug: 'project-a', total: 2 }),
]))
})
it('ignores stale same-board fetch responses after a newer request', async () => {
let resolveSlow: (value: unknown) => void = () => {}
mockKanbanApi.listTasks
.mockImplementationOnce(() => new Promise(resolve => { resolveSlow = resolve }))
.mockResolvedValueOnce([{ id: 'new-filter-task' }])
const store = useKanbanStore()
store.setSelectedBoard('project-a')
const slowFetch = store.fetchTasks()
await store.fetchTasks()
resolveSlow([{ id: 'old-filter-task' }])
await slowFetch
expect(store.tasks).toEqual([{ id: 'new-filter-task' }])
})
it('does not leave loading stuck when a silent fetch supersedes a visible fetch', async () => {
let resolveVisible: (value: unknown) => void = () => {}
mockKanbanApi.listTasks
.mockImplementationOnce(() => new Promise(resolve => { resolveVisible = resolve }))
.mockResolvedValueOnce([{ id: 'silent-task' }])
const store = useKanbanStore()
const visibleFetch = store.fetchTasks()
expect(store.loading).toBe(true)
await store.fetchTasks(true)
resolveVisible([{ id: 'visible-task' }])
await visibleFetch
expect(store.tasks).toEqual([{ id: 'silent-task' }])
expect(store.loading).toBe(false)
})
it('ignores stale fetch responses after a board switch', async () => {
let resolveSlow: (value: unknown) => void = () => {}
mockKanbanApi.listTasks
.mockImplementationOnce(() => new Promise(resolve => { resolveSlow = resolve }))
.mockResolvedValueOnce([{ id: 'new-board-task' }])
const store = useKanbanStore()
store.setSelectedBoard('default')
const slowFetch = store.fetchTasks()
store.setSelectedBoard('project-a')
await store.fetchTasks()
resolveSlow([{ id: 'old-board-task' }])
await slowFetch
expect(store.tasks).toEqual([{ id: 'new-board-task' }])
})
})
+80
View File
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, number>) => {
if (key === 'kanban.card.timeAgo.justNow') return '刚刚'
if (key === 'kanban.card.timeAgo.minutes') return `${params?.count}分钟前`
if (key === 'kanban.card.timeAgo.hours') return `${params?.count}小时前`
if (key === 'kanban.card.timeAgo.days') return `${params?.count}天前`
if (key === 'kanban.card.priority.high') return '高'
if (key === 'kanban.card.priority.medium') return '中'
if (key === 'kanban.card.priority.low') return '低'
if (key === 'kanban.card.assigneeTooltip') return '负责人'
return key
},
}),
}))
vi.mock('naive-ui', () => ({
NTooltip: defineComponent({
name: 'NTooltip',
template: '<div class="n-tooltip-stub"><slot name="trigger" /><div class="tooltip-content"><slot /></div></div>',
}),
}))
vi.mock('@/components/hermes/profiles/ProfileAvatar.vue', () => ({
default: defineComponent({
name: 'ProfileAvatar',
props: { name: { type: String, required: true }, avatar: { type: Object, required: false }, size: { type: Number, required: false } },
template: '<span class="assignee-profile-avatar-stub" :data-name="name" :data-avatar-type="avatar?.type || null" :data-avatar-seed="avatar?.seed || null"></span>',
}),
}))
import KanbanTaskCard from '@/components/hermes/kanban/KanbanTaskCard.vue'
describe('KanbanTaskCard i18n', () => {
afterEach(() => {
vi.useRealTimers()
})
it('renders localized priority, tooltip, and relative time', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-08T03:00:00Z'))
const wrapper = mount(KanbanTaskCard, {
props: {
assigneeAvatar: { type: 'generated', seed: 'alice-seed' },
task: {
id: 'task-1',
title: 'Ship kanban i18n',
body: 'Body preview content',
assignee: 'alice',
status: 'todo',
priority: 3,
created_by: null,
created_at: Math.floor(new Date('2026-05-08T02:58:00Z').getTime() / 1000),
started_at: null,
completed_at: null,
workspace_kind: 'local',
workspace_path: null,
tenant: null,
result: null,
skills: null,
},
},
})
expect(wrapper.text()).toContain('高')
expect(wrapper.text()).toContain('2分钟前')
expect(wrapper.text()).toContain('负责人')
expect(wrapper.classes()).toContain('status-todo')
const avatar = wrapper.find('.assignee-profile-avatar-stub')
expect(avatar.attributes('data-name')).toBe('alice')
expect(avatar.attributes('data-avatar-type')).toBe('generated')
expect(avatar.attributes('data-avatar-seed')).toBe('alice-seed')
})
})
+343
View File
@@ -0,0 +1,343 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
const mockGetTask = vi.hoisted(() => vi.fn())
const mockRequest = vi.hoisted(() => vi.fn())
const mockCompleteTasks = vi.hoisted(() => vi.fn())
const mockBlockTask = vi.hoisted(() => vi.fn())
const mockUnblockTasks = vi.hoisted(() => vi.fn())
const mockAssignTask = vi.hoisted(() => vi.fn())
const mockRouterPush = vi.hoisted(() => vi.fn())
const mockUseMessage = vi.hoisted(() => vi.fn(() => ({
success: vi.fn(),
error: vi.fn(),
})))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@/api/client', () => ({
request: mockRequest,
}))
vi.mock('@/api/hermes/kanban', () => ({
getTask: mockGetTask,
}))
vi.mock('@/stores/hermes/kanban', () => ({
useKanbanStore: () => ({
selectedBoard: 'project-a',
assignees: [{ name: 'alice', counts: { todo: 1 } }, { name: 'bob', counts: { ready: 1 } }],
completeTasks: mockCompleteTasks,
blockTask: mockBlockTask,
unblockTasks: mockUnblockTasks,
assignTask: mockAssignTask,
}),
}))
vi.mock('@/components/hermes/chat/HistoryMessageList.vue', () => ({
default: defineComponent({
name: 'HistoryMessageList',
props: { session: { type: Object, required: false } },
template: '<div class="history-message-list-stub">{{ session ? session.id : "none" }}</div>',
}),
}))
vi.mock('naive-ui', () => ({
NDrawer: defineComponent({
name: 'NDrawer',
props: { show: { type: Boolean, required: false } },
emits: ['update:show'],
template: '<div class="n-drawer-stub"><slot /></div>',
}),
NDrawerContent: defineComponent({
name: 'NDrawerContent',
props: { title: { type: String, required: false }, closable: { type: Boolean, required: false } },
template: '<div class="n-drawer-content-stub"><slot /></div>',
}),
NButton: defineComponent({
name: 'NButton',
emits: ['click'],
template: '<button class="n-button-stub" @click="$emit(\'click\')"><slot /></button>',
}),
NSelect: defineComponent({
name: 'NSelect',
props: { value: { required: false }, options: { type: Array, default: () => [] } },
emits: ['update:value'],
template: '<select class="n-select-stub" @change="$emit(\'update:value\', $event.target.value || null)"><option value=""></option><option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option></select>',
}),
NInput: defineComponent({
name: 'NInput',
props: { value: { required: false }, size: { type: String, required: false }, placeholder: { type: String, required: false } },
emits: ['update:value'],
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
}),
NSpin: defineComponent({
name: 'NSpin',
template: '<div class="n-spin-stub"><slot /></div>',
}),
NModal: defineComponent({
name: 'NModal',
props: { show: { type: Boolean, required: false }, title: { type: String, required: false } },
emits: ['close'],
template: '<div v-if="show" class="n-modal-stub" :data-title="title"><slot /></div>',
}),
useMessage: mockUseMessage,
}))
import KanbanTaskDrawer from '@/components/hermes/kanban/KanbanTaskDrawer.vue'
describe('KanbanTaskDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRequest.mockResolvedValue({ results: [] })
mockCompleteTasks.mockResolvedValue(undefined)
mockBlockTask.mockResolvedValue(undefined)
mockUnblockTasks.mockResolvedValue(undefined)
mockAssignTask.mockResolvedValue(undefined)
mockGetTask.mockResolvedValue({
task: {
id: 'task-1',
title: 'Ship kanban',
body: 'Implement feature',
assignee: 'alice',
status: 'done',
priority: 2,
created_at: 100,
started_at: 110,
completed_at: 120,
tenant: null,
result: 'Done summary',
},
latest_summary: 'Done summary',
comments: [],
events: [],
runs: [{ id: 1, profile: 'alice', status: 'done', started_at: 110, ended_at: 120 }],
session: {
id: 'session-1',
title: 'Hermes session',
source: 'codex',
model: 'gpt-5.5',
started_at: 110,
ended_at: 120,
messages: [
{ id: 'm1', role: 'user', content: 'hello', timestamp: 111 },
{ id: 'm2', role: 'assistant', content: 'world', timestamp: 112 },
{ id: 'm3', role: 'tool', content: 'ignore', timestamp: 113 },
],
},
})
})
it('renders completed-result messages through HistoryMessageList', async () => {
const wrapper = mount(KanbanTaskDrawer, {
props: { taskId: 'task-1' },
})
await flushPromises()
await wrapper.find('.result-summary').trigger('click')
await flushPromises()
const modal = wrapper.find('.n-modal-stub')
expect(modal.exists()).toBe(true)
expect(modal.attributes('data-title')).toBe('Ship kanban')
const history = wrapper.find('.history-message-list-stub')
expect(history.exists()).toBe(true)
expect(history.text()).toBe('session-1')
const sessionProp = wrapper.getComponent({ name: 'HistoryMessageList' }).props('session') as any
expect(sessionProp.messages).toEqual([
{ id: 'm1', role: 'user', content: 'hello', timestamp: 111 },
{ id: 'm2', role: 'assistant', content: 'world', timestamp: 112 },
])
})
it('uses the latest run profile when searching related sessions', async () => {
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-2',
title: 'Retry task',
body: null,
assignee: 'bob',
status: 'running',
priority: 2,
created_at: 100,
started_at: 110,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [
{ id: 1, profile: 'stale', status: 'failed', started_at: 110, ended_at: 120 },
{ id: 2, profile: 'fresh', status: 'running', started_at: 130, ended_at: null },
],
})
mockRequest.mockResolvedValueOnce({
results: [{ id: 'session-2', title: 'Found session', source: 'codex', model: 'gpt-5.5', started_at: 130 }],
})
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-2' } })
await flushPromises()
const sessionsTitle = wrapper.findAll('.section-title').find(node => node.text() === 'kanban.detail.sessions')
await sessionsTitle?.trigger('click')
await flushPromises()
expect(mockRequest).toHaveBeenCalledWith('/api/hermes/kanban/search-sessions?task_id=task-2&profile=fresh&board=project-a')
await wrapper.find('.session-item').trigger('click')
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'hermes.chat', query: { session: 'session-2' } })
})
it('does not expose mutation actions for archived tasks', async () => {
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-archived',
title: 'Archived task',
body: null,
assignee: 'alice',
status: 'archived',
priority: 1,
created_at: 100,
started_at: 110,
completed_at: 120,
tenant: null,
result: 'Archived summary',
},
latest_summary: 'Archived summary',
comments: [],
events: [],
runs: [],
})
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-archived' } })
await flushPromises()
expect(wrapper.text()).not.toContain('kanban.action.complete')
expect(wrapper.text()).not.toContain('kanban.action.block')
expect(wrapper.text()).not.toContain('kanban.action.assign')
})
it('executes complete, block, unblock, and assign actions', async () => {
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-0',
title: 'Todo task',
body: null,
assignee: null,
status: 'todo',
priority: 1,
created_at: 100,
started_at: null,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [],
})
const wrapper = mount(KanbanTaskDrawer, {
props: { taskId: 'task-0' },
})
await flushPromises()
const buttons = wrapper.findAll('.n-button-stub')
await buttons.find(node => node.text() === 'kanban.action.complete')?.trigger('click')
await wrapper.find('.n-input-stub').setValue('done summary')
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'common.ok')?.trigger('click')
await flushPromises()
expect(mockCompleteTasks).toHaveBeenCalledWith(['task-0'], 'done summary')
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-3',
title: 'Blocked task',
body: null,
assignee: 'alice',
status: 'blocked',
priority: 1,
created_at: 100,
started_at: null,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [],
})
await wrapper.setProps({ taskId: 'task-3' })
await flushPromises()
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.unblock')?.trigger('click')
expect(mockUnblockTasks).toHaveBeenCalledWith(['task-3'])
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-4',
title: 'Todo task',
body: null,
assignee: null,
status: 'todo',
priority: 1,
created_at: 100,
started_at: null,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [],
})
mockGetTask.mockResolvedValueOnce({
task: {
id: 'task-4',
title: 'Todo task',
body: null,
assignee: 'bob',
status: 'todo',
priority: 1,
created_at: 100,
started_at: null,
completed_at: null,
tenant: null,
result: null,
},
latest_summary: null,
comments: [],
events: [],
runs: [],
})
await wrapper.setProps({ taskId: 'task-4' })
await flushPromises()
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.block')?.trigger('click')
await wrapper.find('.n-input-stub').setValue('waiting dependency')
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'common.ok')?.trigger('click')
expect(mockBlockTask).toHaveBeenCalledWith('task-4', 'waiting dependency')
const select = wrapper.find('.n-select-stub')
await select.setValue('bob')
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.assign')?.trigger('click')
await flushPromises()
expect(mockAssignTask).toHaveBeenCalledWith('task-4', 'bob')
})
})
+307
View File
@@ -0,0 +1,307 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
const routeState = vi.hoisted(() => ({
query: { board: 'project-a' } as Record<string, string>,
}))
const routerReplace = vi.hoisted(() => vi.fn())
const storeState = vi.hoisted(() => ({
tasks: [] as Array<{ id: string; title: string; status: string; created_at: number; assignee?: string | null }>,
stats: { by_status: { todo: 1, done: 0 }, by_assignee: {}, total: 1 } as Record<string, any>,
assignees: [] as Array<{ name: string; counts: Record<string, number> | null }>,
activeBoards: [] as Array<{ slug: string; name: string; icon?: string; total?: number }>,
loading: false,
boardsLoading: false,
selectedBoard: 'default',
boardWarning: null as string | null,
capabilities: null as Record<string, any> | null,
filterStatus: null as string | null,
filterAssignee: null as string | null,
}))
const mockFetchBoards = vi.hoisted(() => vi.fn())
const mockFetchCapabilities = vi.hoisted(() => vi.fn())
const mockRefreshAll = vi.hoisted(() => vi.fn())
const mockFetchTasks = vi.hoisted(() => vi.fn())
const mockFetchStats = vi.hoisted(() => vi.fn())
const mockSetFilter = vi.hoisted(() => vi.fn())
const mockRecoverSelectedBoard = vi.hoisted(() => vi.fn())
const mockCreateBoard = vi.hoisted(() => vi.fn())
const mockArchiveSelectedBoard = vi.hoisted(() => vi.fn())
const mockStartEventStream = vi.hoisted(() => vi.fn())
const mockStopEventStream = vi.hoisted(() => vi.fn())
const mockFetchProfiles = vi.hoisted(() => vi.fn())
const profilesState = vi.hoisted(() => ({
profiles: [] as Array<{ name: string; avatar?: Record<string, any> | null }>,
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({ replace: routerReplace }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/stores/hermes/kanban', () => ({
DEFAULT_KANBAN_BOARD: 'default',
useKanbanStore: () => ({
...storeState,
fetchBoards: mockFetchBoards,
fetchCapabilities: mockFetchCapabilities,
refreshAll: mockRefreshAll,
fetchTasks: mockFetchTasks,
fetchStats: mockFetchStats,
setFilter: mockSetFilter,
recoverSelectedBoard: mockRecoverSelectedBoard,
createBoard: mockCreateBoard,
archiveSelectedBoard: mockArchiveSelectedBoard,
startEventStream: mockStartEventStream,
stopEventStream: mockStopEventStream,
}),
}))
vi.mock('@/stores/hermes/profiles', () => ({
useProfilesStore: () => ({
profiles: profilesState.profiles,
fetchProfiles: mockFetchProfiles,
}),
}))
vi.mock('@/components/hermes/kanban/KanbanTaskCard.vue', () => ({
default: defineComponent({
name: 'KanbanTaskCard',
props: { task: { type: Object, required: true }, assigneeAvatar: { type: Object, required: false } },
template: '<div class="kanban-task-card-stub" :data-avatar-seed="assigneeAvatar?.seed || null">{{ task.title }}</div>',
}),
}))
vi.mock('@/components/hermes/kanban/KanbanTaskDrawer.vue', () => ({
default: defineComponent({
name: 'KanbanTaskDrawer',
emits: ['updated', 'close'],
template: '<button class="drawer-updated" @click="$emit(\'updated\')">drawer</button>',
}),
}))
vi.mock('@/components/hermes/kanban/KanbanCreateForm.vue', () => ({
default: defineComponent({
name: 'KanbanCreateForm',
emits: ['created', 'close'],
template: '<button class="form-created" @click="$emit(\'created\')">form</button>',
}),
}))
vi.mock('naive-ui', () => ({
useMessage: () => ({ warning: vi.fn(), error: vi.fn(), success: vi.fn() }),
NButton: defineComponent({
name: 'NButton',
emits: ['click'],
template: '<button class="n-button-stub" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
}),
NSelect: defineComponent({
name: 'NSelect',
props: { value: null, options: { type: Array, default: () => [] }, loading: Boolean },
emits: ['update:value'],
template: '<button class="n-select-stub" @click="$emit(\'update:value\', options[1]?.value || value)"><span v-for="option in options" :key="option.value">{{ option.label }}</span>{{ value }}</button>',
}),
NInput: defineComponent({
name: 'NInput',
props: { value: { type: String, default: '' }, placeholder: { type: String, required: false } },
emits: ['update:value'],
template: '<input class="n-input-stub" :placeholder="placeholder" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
}),
NModal: defineComponent({
name: 'NModal',
props: { show: Boolean },
emits: ['update:show', 'close'],
template: '<div v-if="show" class="n-modal-stub"><slot /><slot name="action" /></div>',
}),
NSpin: defineComponent({
name: 'NSpin',
template: '<div class="n-spin-stub"><slot /></div>',
}),
NCollapse: defineComponent({
name: 'NCollapse',
props: { expandedNames: { type: Array, required: false }, defaultExpandedNames: { type: Array, required: false } },
emits: ['update:expandedNames'],
template: '<div class="n-collapse-stub" :data-expanded="JSON.stringify(expandedNames ?? null)" :data-default-expanded="JSON.stringify(defaultExpandedNames ?? null)"><slot /></div>',
}),
NCollapseItem: defineComponent({
name: 'NCollapseItem',
props: { title: { type: String, required: false }, name: { type: String, required: false } },
template: '<section class="n-collapse-item-stub" :data-name="name"><slot /></section>',
}),
}))
import KanbanView from '@/views/hermes/KanbanView.vue'
describe('KanbanView', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
routeState.query = { board: 'project-a' }
routerReplace.mockResolvedValue(undefined)
storeState.tasks = [
{ id: 'task-1', title: 'Task one', status: 'todo', created_at: 10 },
{ id: 'task-2', title: 'Task two', status: 'done', created_at: 20 },
]
storeState.stats = {
by_status: { triage: 0, todo: 1, ready: 0, running: 0, blocked: 0, done: 1, archived: 0 },
by_assignee: {},
total: 2,
}
storeState.assignees = []
storeState.activeBoards = [
{ slug: 'default', name: 'Default', total: 0 },
{ slug: 'project-a', name: 'Project A', total: 2 },
]
storeState.loading = false
storeState.boardsLoading = false
storeState.selectedBoard = 'default'
storeState.boardWarning = null
storeState.capabilities = null
storeState.filterStatus = null
storeState.filterAssignee = null
profilesState.profiles = []
mockFetchBoards.mockResolvedValue(undefined)
mockFetchCapabilities.mockResolvedValue(undefined)
mockRefreshAll.mockResolvedValue(undefined)
mockFetchTasks.mockResolvedValue(undefined)
mockFetchStats.mockResolvedValue(undefined)
mockFetchProfiles.mockResolvedValue(undefined)
mockCreateBoard.mockResolvedValue({ slug: 'new-board' })
mockArchiveSelectedBoard.mockResolvedValue(undefined)
mockRecoverSelectedBoard.mockImplementation((candidate: string) => {
storeState.selectedBoard = candidate || 'default'
return { board: storeState.selectedBoard, recovered: false }
})
mockSetFilter.mockImplementation((key: 'status' | 'assignee', value: string | null) => {
if (key === 'status') storeState.filterStatus = value
else storeState.filterAssignee = value
})
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'visible',
})
})
it('initializes board from route query and refreshes stats alongside tasks', async () => {
const wrapper = mount(KanbanView)
await flushPromises()
expect(mockFetchBoards).toHaveBeenCalledOnce()
expect(mockFetchCapabilities).toHaveBeenCalledOnce()
expect(mockFetchProfiles).toHaveBeenCalledOnce()
expect(mockRecoverSelectedBoard).toHaveBeenCalledWith('project-a')
expect(mockRefreshAll).toHaveBeenCalledOnce()
expect(routerReplace).not.toHaveBeenCalled()
expect(wrapper.find('.n-collapse-stub').attributes('data-expanded')).toBe('["triage","todo","ready","running","blocked","done","archived"]')
await wrapper.find('.drawer-updated').trigger('click')
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
expect(mockFetchStats).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(15000)
await flushPromises()
expect(mockFetchBoards).toHaveBeenCalledTimes(2)
expect(mockFetchTasks).toHaveBeenCalledTimes(2)
expect(mockFetchStats).toHaveBeenCalledTimes(2)
})
it('renders board count labels and compact assignee profile labels', async () => {
storeState.assignees = [{ name: 'alice', counts: { todo: 2, done: 1 } }]
const wrapper = mount(KanbanView)
await flushPromises()
expect(wrapper.text()).toContain('kanban.title: Default · kanban.stats.tasks: 0')
expect(wrapper.text()).toContain('kanban.title: Project A · kanban.stats.tasks: 2')
const assigneeSelect = wrapper.findAll('.n-select-stub')[2]
expect(assigneeSelect.text()).toContain('alice')
expect(assigneeSelect.text()).not.toContain('default')
expect(wrapper.text()).not.toContain('kanban.detail.assignee: alice')
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
})
it('passes matching profile avatars to task cards', async () => {
storeState.tasks = [{ id: 'task-1', title: 'Task one', status: 'todo', created_at: 10, assignee: 'alice' }]
profilesState.profiles = [{ name: 'alice', avatar: { type: 'generated', seed: 'alice-seed' } }]
const wrapper = mount(KanbanView)
await flushPromises()
expect(wrapper.find('.kanban-task-card-stub').attributes('data-avatar-seed')).toBe('alice-seed')
})
it('filters the visible board columns from stats chips', async () => {
storeState.filterStatus = 'done'
const wrapper = mount(KanbanView)
await flushPromises()
const columns = wrapper.findAll('.n-collapse-item-stub')
expect(wrapper.find('.n-collapse-stub').attributes('data-expanded')).toBe('["done"]')
expect(columns).toHaveLength(1)
expect(columns[0].attributes('data-name')).toBe('done')
expect(wrapper.text()).toContain('Task two')
expect(wrapper.text()).not.toContain('Task one')
await wrapper.find('.stat-chip.todo').trigger('click')
await flushPromises()
expect(mockSetFilter).toHaveBeenCalledWith('status', 'todo')
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
await wrapper.find('.stat-chip.total').trigger('click')
await flushPromises()
expect(mockSetFilter).toHaveBeenCalledWith('status', null)
expect(mockFetchTasks).toHaveBeenCalledTimes(2)
})
it('creates and archives boards from the board toolbar', async () => {
storeState.selectedBoard = 'project-a'
const wrapper = mount(KanbanView)
await flushPromises()
await wrapper.findAll('.n-button-stub')[0].trigger('click')
await flushPromises()
const inputs = wrapper.findAll('.n-input-stub')
await inputs[0].setValue('new-board')
await inputs[1].setValue('New Board')
await wrapper.findAll('.n-button-stub').at(-1)!.trigger('click')
await flushPromises()
expect(mockCreateBoard).toHaveBeenCalledWith({ slug: 'new-board', name: 'New Board' })
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'new-board' } })
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
await wrapper.findAll('.n-button-stub')[1].trigger('click')
await flushPromises()
expect(mockArchiveSelectedBoard).toHaveBeenCalled()
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'default' } })
})
it('makes default board explicit when route query is absent', async () => {
routeState.query = {}
mockRecoverSelectedBoard.mockImplementation(() => {
storeState.selectedBoard = 'default'
return { board: 'default', recovered: false }
})
mount(KanbanView)
await flushPromises()
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'default' } })
expect(mockRefreshAll).toHaveBeenCalledOnce()
})
})
+97
View File
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const mockReplace = vi.hoisted(() => vi.fn())
const mockFetchAuthStatus = vi.hoisted(() => vi.fn())
const mockLoginWithPassword = vi.hoisted(() => vi.fn())
const mockSetApiKey = vi.hoisted(() => vi.fn())
const mockHasApiKey = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/api/client', () => ({
setApiKey: mockSetApiKey,
hasApiKey: mockHasApiKey,
}))
vi.mock('@/api/auth', () => ({
fetchAuthStatus: mockFetchAuthStatus,
loginWithPassword: mockLoginWithPassword,
}))
import LoginView from '@/views/LoginView.vue'
describe('LoginView password login', () => {
beforeEach(() => {
delete (window as any).__LOGIN_TOKEN__
vi.clearAllMocks()
mockHasApiKey.mockReturnValue(false)
mockFetchAuthStatus.mockResolvedValue({ hasPasswordLogin: true, username: 'admin' })
})
it('logs in with username and password', async () => {
mockLoginWithPassword.mockResolvedValue('jwt-token')
const wrapper = mount(LoginView)
const inputs = wrapper.findAll('input.login-input')
await inputs[0].setValue('admin')
await inputs[1].setValue('123456')
await wrapper.find('form.login-form').trigger('submit')
expect(mockLoginWithPassword).toHaveBeenCalledWith('admin', '123456')
expect(mockSetApiKey).toHaveBeenCalledWith('jwt-token')
expect(mockReplace).toHaveBeenCalledWith('/hermes/chat')
})
it('shows the default login hint', () => {
const wrapper = mount(LoginView)
expect(wrapper.text()).toContain('login.defaultCredentialsHint')
})
it('shows an error when password login fails', async () => {
mockLoginWithPassword.mockRejectedValue(new Error('Invalid username or password'))
const wrapper = mount(LoginView)
const inputs = wrapper.findAll('input.login-input')
await inputs[0].setValue('admin')
await inputs[1].setValue('bad-password')
await wrapper.find('form.login-form').trigger('submit')
expect(wrapper.find('.login-error').text()).toBe('Invalid username or password')
expect(mockSetApiKey).not.toHaveBeenCalled()
expect(mockReplace).not.toHaveBeenCalled()
})
it('shows the reset command hint when the login IP is locked', async () => {
const err: any = new Error('Too many login attempts')
err.status = 429
mockLoginWithPassword.mockRejectedValue(err)
const wrapper = mount(LoginView)
const inputs = wrapper.findAll('input.login-input')
await inputs[0].setValue('admin')
await inputs[1].setValue('123456')
await wrapper.find('form.login-form').trigger('submit')
expect(wrapper.find('.login-error').text()).toBe('login.tooManyAttempts')
expect(wrapper.find('.login-lock-hint').text()).toContain('login.lockResetHint')
expect(wrapper.find('.login-lock-hint').text()).toContain('login.defaultLoginResetHint')
const commands = wrapper.findAll('.login-lock-hint code').map(command => command.text())
expect(commands).toEqual([
'hermes-web-ui clear-login-locks --restart',
'hermes-web-ui reset-default-login',
])
})
})
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
vi.mock('mermaid', () => new Promise(() => {}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('naive-ui', () => ({
NDrawer: {
props: ['show'],
template: '<div v-if="show"><slot /></div>',
},
NDrawerContent: {
template: '<section><slot /></section>',
},
NSpin: {
template: '<div><slot /></div>',
},
useMessage: () => ({
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
async function flushMermaidRender(): Promise<void> {
for (let i = 0; i < 16; i += 1) {
await nextTick()
await Promise.resolve()
}
}
describe('MarkdownRenderer Mermaid import timeout', () => {
afterEach(() => {
vi.useRealTimers()
})
it('falls back to copyable code when the mermaid dynamic import never settles', async () => {
vi.useFakeTimers()
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```mermaid\nflowchart TD\nA --> B\n```',
},
})
await nextTick()
await Promise.resolve()
await vi.advanceTimersByTimeAsync(5_001)
await flushMermaidRender()
expect(wrapper.find('.mermaid-loading').exists()).toBe(false)
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
})
})
+712
View File
@@ -0,0 +1,712 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn(async (id: string, source: string) => ({
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
})),
}))
const downloadApiMock = vi.hoisted(() => ({
downloadFile: vi.fn(() => Promise.resolve()),
fetchFileText: vi.fn(() => Promise.resolve('preview content')),
getDownloadUrl: vi.fn((path: string) => `http://test.local/api/hermes/download?path=${encodeURIComponent(path)}`),
}))
vi.mock('mermaid', () => ({
default: mermaidMock,
}))
async function flushMermaidRender(): Promise<void> {
for (let i = 0; i < 16; i += 1) {
await nextTick()
await Promise.resolve()
}
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('naive-ui', () => ({
NDrawer: {
props: ['show', 'width'],
template: '<div v-if="show" class="n-drawer-stub" :data-width="width"><slot /></div>',
},
NDrawerContent: {
props: {
title: { type: String, default: '' },
closable: { type: Boolean, default: false },
bodyContentStyle: { type: [Object, String], default: undefined },
},
template: '<section class="n-drawer-content-stub" :data-body-padding="bodyContentStyle && bodyContentStyle.padding"><header class="n-drawer-header-stub">{{ title }}<button v-if="closable" class="n-drawer-close-stub" @click="$emit(\'close\')">x</button></header><slot /></section>',
},
NSpin: {
props: ['show'],
template: '<div class="n-spin-stub"><slot /></div>',
},
useMessage: () => ({
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
vi.mock('@/api/hermes/download', () => ({
downloadFile: downloadApiMock.downloadFile,
fetchFileText: downloadApiMock.fetchFileText,
getDownloadUrl: downloadApiMock.getDownloadUrl,
}))
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
describe('MarkdownRenderer', () => {
afterEach(() => {
vi.useRealTimers()
})
beforeEach(() => {
mermaidMock.initialize.mockClear()
mermaidMock.render.mockClear()
downloadApiMock.downloadFile.mockClear()
downloadApiMock.fetchFileText.mockClear()
downloadApiMock.getDownloadUrl.mockClear()
mermaidMock.render.mockImplementation(async (id: string, source: string) => ({
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
}))
downloadApiMock.downloadFile.mockResolvedValue(undefined)
downloadApiMock.fetchFileText.mockResolvedValue('preview content')
downloadApiMock.getDownloadUrl.mockImplementation((path: string) => `http://test.local/api/hermes/download?path=${encodeURIComponent(path)}`)
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
})
})
it('highlights vue fenced blocks instead of rendering them as plain text', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```vue\n<template><div>Hello</div></template>\n```',
},
})
expect(wrapper.find('.code-lang').text()).toBe('vue')
expect(wrapper.find('code.hljs').html()).toContain('hljs-tag')
})
it('keeps shell-session fences on the shell grammar', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```shell\n$ ls\nfoo.txt\n```',
},
})
expect(wrapper.find('.code-lang').text()).toBe('shell')
expect(wrapper.find('code.hljs').html()).toContain('hljs-meta')
})
it('still highlights long supported code fences', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: `\`\`\`json\n${JSON.stringify({ content: 'x'.repeat(2500), ok: true })}\n\`\`\``,
},
})
expect(wrapper.find('.code-lang').text()).toBe('json')
expect(wrapper.find('code.hljs').html()).toMatch(/hljs-(attr|string|punctuation)/)
})
it('falls back to plain escaped text when a fence language is unsupported', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```foobar\n{"answer":42,"ok":true}\n```',
},
})
expect(wrapper.find('.code-lang').text()).toBe('foobar')
expect(wrapper.find('code.hljs').findAll('span')).toHaveLength(0)
expect(wrapper.find('code.hljs').text()).toContain('{"answer":42,"ok":true}')
})
it('keeps unlabeled code fences as plain text instead of guessing a grammar', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```\nINFO Starting server\nConnected to 127.0.0.1\nDone\n```',
},
})
expect(wrapper.find('.code-lang').text()).toBe('text')
expect(wrapper.find('code.hljs').findAll('span')).toHaveLength(0)
expect(wrapper.find('code.hljs').text()).toContain('INFO Starting server')
})
it('renders outer markdown draft fences as markdown while preserving nested fenced examples', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: [
'下面是可直接手动编辑的 PR draft。',
'',
'```md',
'标题: fix(chat): 保留附件在同一聊天后续轮次的上下文',
'',
'## Summary',
'',
'附件上传后,首轮 `startRun()` 的 `input` 已包含上传文件引用:',
'',
'```md',
'[File: screenshot.png](/uploaded/path)',
'```',
'',
'但本地保存的用户消息只保留 UI 可见文本。',
'',
'## Fix',
'- Preserve context.',
'```',
].join('\n'),
},
})
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
expect(wrapper.find('.code-lang').text()).toBe('md')
expect(wrapper.find('code.hljs').text()).toContain('[File: screenshot.png](/uploaded/path)')
expect(wrapper.find('.markdown-body').findAll('h2')).toHaveLength(2)
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Summary')
expect(wrapper.find('.markdown-body').text()).toContain('但本地保存的用户消息只保留 UI 可见文本。')
expect(wrapper.find('.markdown-body').text()).toContain('Preserve context.')
})
it('keeps markdown examples with their own nested fences intact after unwrapping a draft fence', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: [
'```md',
'## Regression Coverage',
'',
'```md',
'下面是一个 PR draft。',
'',
'```md',
'[File: Screenshot.png](/tmp/example.png)',
'```',
'',
'## Fix',
'',
'- 后续 heading 不应被截断。',
'```',
'',
'## Local Verification',
'',
'- localhost renders after the example.',
'```',
].join('\n'),
},
})
const headings = wrapper.find('.markdown-body').findAll('h2').map(heading => heading.text())
expect(headings).toEqual(['Regression Coverage', 'Local Verification'])
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
const codeText = wrapper.find('code.hljs').text()
expect(codeText).toContain('下面是一个 PR draft。')
expect(codeText).toContain('```md\n[File: Screenshot.png](/tmp/example.png)\n```')
expect(codeText).toContain('## Fix')
expect(codeText).toContain('- 后续 heading 不应被截断。')
expect(wrapper.find('.markdown-body').text()).toContain('localhost renders after the example.')
})
it('keeps markdown examples with unlabeled nested fences intact', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: [
'```md',
'## Unlabeled Fence Example',
'',
'```md',
'```',
'plain nested block',
'```',
'```',
'',
'Done outside.',
'```',
].join('\n'),
},
})
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Unlabeled Fence Example')
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
expect(wrapper.find('code.hljs').text()).toContain('```\nplain nested block\n```')
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
})
it('renders local mov links as inline video players', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '[录屏2026-05-08 15.19.46.mov](/Users/ekko/Desktop/录屏2026-05-08%2015.19.46.mov)',
},
})
const video = wrapper.find('video.markdown-video')
expect(video.exists()).toBe(true)
expect(video.attributes('src')).toContain('/api/hermes/download?path=')
const src = new URL(video.attributes('src'))
expect(decodeURIComponent(src.searchParams.get('path') || '')).toBe('/Users/ekko/Desktop/录屏2026-05-08 15.19.46.mov')
expect(wrapper.find('.markdown-video-footer .att-name').text()).toBe('录屏2026-05-08 15.19.46.mov')
})
it('renders MSYS-style Windows image paths through the download endpoint', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '![桌面截图](/c/Users/Administrator/Desktop/screenshot.png)',
},
})
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toContain('/api/hermes/download?path=')
const src = new URL(img.attributes('src'))
expect(decodeURIComponent(src.searchParams.get('path') || '')).toBe('/c/Users/Administrator/Desktop/screenshot.png')
expect(img.attributes('alt')).toBe('桌面截图')
})
it('downloads local text files when the file card download icon is clicked', async () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '[notes.txt](/tmp/notes.txt)',
},
})
expect(wrapper.find('.markdown-file-card').exists()).toBe(true)
expect(wrapper.find('.att-download-btn .att-download-icon').exists()).toBe(true)
await wrapper.find('.att-download-btn').trigger('click')
await Promise.resolve()
expect(downloadApiMock.downloadFile).toHaveBeenCalledTimes(1)
expect(downloadApiMock.downloadFile).toHaveBeenCalledWith('/tmp/notes.txt', 'notes.txt')
expect(downloadApiMock.fetchFileText).not.toHaveBeenCalled()
expect(wrapper.find('.n-drawer-stub').exists()).toBe(false)
})
it('opens text previews in a responsive drawer with a close control', async () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '[notes.txt](/tmp/notes.txt)',
},
})
await wrapper.find('.markdown-file-card').trigger('click')
await Promise.resolve()
await nextTick()
const drawer = wrapper.find('.n-drawer-stub')
expect(drawer.exists()).toBe(true)
expect(drawer.attributes('data-width')).toBe('min(800px, 100vw)')
expect(drawer.find('.n-drawer-content-stub').attributes('data-body-padding')).toBe('0')
expect(drawer.text()).toContain('download.contentDisplay')
expect(downloadApiMock.fetchFileText).toHaveBeenCalledWith('/tmp/notes.txt', 'notes.txt')
await drawer.find('.n-drawer-close-stub').trigger('click')
await nextTick()
expect(wrapper.find('.n-drawer-stub').exists()).toBe(false)
})
it('renders markdown file previews as markdown content', async () => {
downloadApiMock.fetchFileText.mockResolvedValue('# Preview Title\n\n**bold text**')
const wrapper = mount(MarkdownRenderer, {
props: {
content: '[notes.md](/tmp/notes.md)',
},
})
await wrapper.find('.markdown-file-card').trigger('click')
await Promise.resolve()
await nextTick()
const drawer = wrapper.find('.n-drawer-stub')
expect(drawer.exists()).toBe(true)
expect(drawer.find('.text-preview-markdown').exists()).toBe(true)
expect(drawer.find('.text-preview-body').exists()).toBe(false)
expect(drawer.find('.text-preview-markdown h1').text()).toBe('Preview Title')
expect(drawer.find('.text-preview-markdown strong').text()).toBe('bold text')
})
it('keeps tilde-fenced markdown examples with nested tilde fences intact', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: [
'```md',
'## Tilde Example',
'',
'~~~md',
'~~~yaml',
'ok: true',
'~~~',
'~~~',
'',
'Done outside.',
'```',
].join('\n'),
},
})
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Tilde Example')
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
expect(wrapper.find('code.hljs').text()).toContain('~~~yaml\nok: true\n~~~')
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
})
it('keeps already-valid longer markdown example fences valid', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: [
'```md',
'## Longer Fence Example',
'',
'````md',
'```ts',
'const answer = 42',
'```',
'````',
'',
'Done outside.',
'```',
].join('\n'),
},
})
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Longer Fence Example')
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
expect(wrapper.find('code.hljs').text()).toContain('```ts\nconst answer = 42\n```')
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
})
it('renders mermaid fences as diagrams instead of raw highlighted code', async () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: [
'```mermaid',
'flowchart TD',
'A[User] --> B[Web UI<br/>command]',
'```',
'',
'具体 behavior:',
'- Markdown below still renders.',
].join('\n'),
},
})
await flushMermaidRender()
expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({
startOnLoad: false,
securityLevel: 'strict',
}))
expect(mermaidMock.render).toHaveBeenCalledWith(
expect.stringMatching(/^hermes-mermaid-/),
expect.stringContaining('flowchart TD'),
)
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true)
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(0)
expect(wrapper.find('.markdown-body').find('ul').exists()).toBe(true)
})
it('renders mermaid inside repaired outer markdown draft fences', async () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: [
'```md',
'## Command flow',
'',
'```Mermaid title',
'flowchart LR',
'A --> B',
'```',
'',
'Done outside.',
'```',
].join('\n'),
},
})
await flushMermaidRender()
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Command flow')
expect(mermaidMock.render).toHaveBeenCalledWith(
expect.stringMatching(/^hermes-mermaid-/),
expect.stringContaining('flowchart LR'),
)
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true)
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
})
it('falls back to a copyable code block when mermaid rendering fails', async () => {
mermaidMock.render.mockImplementationOnce((id: string) => {
const errorContainer = document.createElement('div')
errorContainer.id = `d${id}`
errorContainer.textContent = 'Syntax error in text\nmermaid version 11.14.0'
document.body.appendChild(errorContainer)
return Promise.reject(new Error('bad diagram'))
})
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```mermaid\nnot valid mermaid\n```',
},
})
await flushMermaidRender()
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
expect(wrapper.find('code.hljs').text()).toContain('not valid mermaid')
expect(wrapper.find('[data-copy-code="true"]').exists()).toBe(true)
expect(document.body.textContent).not.toContain('Syntax error in text')
})
it('falls back to copyable code blocks when mermaid initialization fails', async () => {
mermaidMock.initialize.mockImplementationOnce(() => {
throw new Error('init failed')
})
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```mermaid\nflowchart TD\nA --> B\n```',
},
})
await flushMermaidRender()
expect(mermaidMock.render).not.toHaveBeenCalled()
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
})
it('falls back without initializing mermaid when every pending diagram is oversized', async () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: `\`\`\`mermaid\n${'A'.repeat(20_001)}\n\`\`\``,
},
})
await flushMermaidRender()
expect(mermaidMock.initialize).not.toHaveBeenCalled()
expect(mermaidMock.render).not.toHaveBeenCalled()
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
})
it('falls back without initializing mermaid when every pending diagram is empty', async () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```mermaid\n```',
},
})
await flushMermaidRender()
expect(mermaidMock.initialize).not.toHaveBeenCalled()
expect(mermaidMock.render).not.toHaveBeenCalled()
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
})
it('falls back to copyable code when mermaid rendering never settles', async () => {
vi.useFakeTimers()
mermaidMock.render.mockImplementationOnce(() => new Promise(() => {}))
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```mermaid\nflowchart TD\nA --> B\n```',
},
})
await nextTick()
await Promise.resolve()
await vi.advanceTimersByTimeAsync(5_001)
await flushMermaidRender()
expect(wrapper.find('.mermaid-loading').exists()).toBe(false)
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
})
it('does not load or render mermaid when the message has no mermaid block', async () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```ts\nconst answer = 42\n```',
},
})
await flushMermaidRender()
expect(mermaidMock.initialize).not.toHaveBeenCalled()
expect(mermaidMock.render).not.toHaveBeenCalled()
expect(wrapper.find('.code-lang').text()).toBe('ts')
})
it('does not let stale async mermaid renders mutate newer message content', async () => {
let resolveRender: ((value: { svg: string }) => void) | undefined
mermaidMock.render.mockImplementationOnce((id: string) => new Promise(resolve => {
resolveRender = resolve
}))
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```mermaid\nflowchart TD\nA --> B\n```',
},
})
await nextTick()
await wrapper.setProps({ content: 'No diagram now.' })
resolveRender?.({ svg: '<svg data-testid="stale-mermaid-svg"></svg>' })
await flushMermaidRender()
expect(wrapper.find('[data-testid="stale-mermaid-svg"]').exists()).toBe(false)
expect(wrapper.find('.markdown-body').text()).toContain('No diagram now.')
})
it('renders inline latex math with katex', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: 'Pythagoras: $x^2 + y^2 = z^2$.',
},
})
const body = wrapper.find('.markdown-body')
expect(body.find('.katex').exists()).toBe(true)
expect(body.html()).toContain('x')
expect(body.html()).toContain('z')
expect(body.text()).not.toContain('$x^2 + y^2 = z^2$')
})
it('renders display latex math with katex', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '$$\n\\int_0^1 x^2 dx = \\frac{1}{3}\n$$',
},
})
const body = wrapper.find('.markdown-body')
expect(body.find('.katex-display').exists()).toBe(true)
expect(body.find('.katex').exists()).toBe(true)
expect(body.text()).not.toContain('$$')
})
it('renders explicit latex fenced blocks with katex', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```latex\n\\[\\text{Итог} = \\operatorname{Округление}\\!\\left(0.5\\,O_1 + 0.5\\,O_2\\right)\\]\n```',
},
})
const body = wrapper.find('.markdown-body')
expect(body.find('.katex-display').exists()).toBe(true)
expect(body.find('.katex').exists()).toBe(true)
expect(body.text()).not.toContain('```latex')
expect(body.text()).toContain('Округление')
})
it('does not render latex inside ordinary fenced code blocks', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```ts\nconst formula = "$x^2 + y^2 = z^2$"\n```',
},
})
expect(wrapper.find('.markdown-body').find('.katex').exists()).toBe(false)
expect(wrapper.find('code.hljs').text()).toContain('$x^2 + y^2 = z^2$')
})
it('does not treat currency-like dollar text as latex math', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: 'Price is $5 and $6 today.',
},
})
const body = wrapper.find('.markdown-body')
expect(body.find('.katex').exists()).toBe(false)
expect(body.text()).toContain('Price is $5 and $6 today.')
})
it('does not render escaped dollar-delimited text as latex math', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: 'Escaped: \\$x^2$',
},
})
const body = wrapper.find('.markdown-body')
expect(body.find('.katex').exists()).toBe(false)
expect(body.text()).toContain('Escaped: $x^2$')
})
it('keeps rendering when latex syntax is invalid', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: 'Before $\\notacommand{ after',
},
})
expect(wrapper.find('.markdown-body').text()).toContain('Before')
})
it('copies code through the delegated click handler', async () => {
const writeText = vi.mocked(navigator.clipboard.writeText)
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```ts\nconst answer = 42\n```',
},
})
const expected = wrapper.find('code.hljs').element.textContent ?? ''
await wrapper.find('[data-copy-code="true"]').trigger('click')
expect(writeText).toHaveBeenCalledWith(expected)
})
it('falls back to legacy clipboard copy when the Clipboard API is unavailable', async () => {
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: false,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const execCommand = vi.fn(() => true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
})
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```ts\nconst answer = 42\n```',
},
})
await wrapper.find('[data-copy-code="true"]').trigger('click')
expect(execCommand).toHaveBeenCalledWith('copy')
})
})
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('naive-ui', async (importOriginal) => {
const actual = await importOriginal<typeof import('naive-ui')>()
return {
...actual,
useMessage: () => ({
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}
})
vi.mock('@/api/hermes/download', () => ({
downloadFile: vi.fn(),
getDownloadUrl: vi.fn((path: string) => `/download?path=${encodeURIComponent(path)}`),
}))
vi.mock('@/components/hermes/chat/mermaidRenderer', () => ({
renderMermaidDiagram: vi.fn(),
}))
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
describe('MarkdownRenderer special mentions', () => {
it('highlights @all as a mention when provided by group chat', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '@all, please compare options',
mentionNames: ['all', 'Alice'],
},
})
expect(wrapper.find('.mention-highlight').text()).toBe('@all')
})
it('highlights @all at the end of rendered paragraphs and after opening punctuation', () => {
for (const content of ['@all', 'please compare @all', '(@all)']) {
const wrapper = mount(MarkdownRenderer, {
props: {
content,
mentionNames: ['all', 'Alice'],
},
})
expect(wrapper.find('.mention-highlight').text()).toBe('@all')
}
})
it('does not highlight @alligator as @all', () => {
const wrapper = mount(MarkdownRenderer, {
props: {
content: '@alligator should stay plain',
mentionNames: ['all'],
},
})
expect(wrapper.find('.mention-highlight').exists()).toBe(false)
})
})
+222
View File
@@ -0,0 +1,222 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('naive-ui', () => ({
useMessage: () => ({
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
import MessageItem from '@/components/hermes/chat/MessageItem.vue'
import type { Message } from '@/stores/hermes/chat'
describe('MessageItem tool details', () => {
beforeEach(() => {
setActivePinia(createPinia())
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
})
Object.defineProperty(window, 'speechSynthesis', {
configurable: true,
value: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getVoices: vi.fn(() => []),
speak: vi.fn(),
cancel: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
},
})
})
it('renders highlighted code blocks for tool arguments and tool results', async () => {
const wrapper = mount(MessageItem, {
props: {
message: {
id: 'tool-1',
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: 'web_search',
toolArgs: '{"query":"syntax highlighting"}',
toolResult: '{"results":[{"title":"Done"}]}',
toolStatus: 'done',
} satisfies Message,
},
})
await wrapper.find('.tool-line').trigger('click')
const blocks = wrapper.findAll('.tool-details .hljs-code-block')
expect(blocks).toHaveLength(2)
expect(blocks[0].find('.code-lang').text()).toBe('json')
expect(blocks[1].find('.code-lang').text()).toBe('json')
})
it('copies tool detail code through the delegated click handler', async () => {
const writeText = vi.mocked(navigator.clipboard.writeText)
const wrapper = mount(MessageItem, {
props: {
message: {
id: 'tool-copy',
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: 'web_search',
toolArgs: '{"query":"syntax highlighting"}',
toolStatus: 'done',
} satisfies Message,
},
})
await wrapper.find('.tool-line').trigger('click')
const expected = wrapper.find('.tool-details code.hljs').text()
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
expect(writeText).toHaveBeenCalledWith(expected)
})
it('truncates large tool arguments for display but copies the full formatted payload', async () => {
const writeText = vi.mocked(navigator.clipboard.writeText)
const message = {
content: 'x'.repeat(4000),
ok: true,
}
const wrapper = mount(MessageItem, {
props: {
message: {
id: 'tool-args-large',
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: 'write_file',
toolArgs: JSON.stringify(message),
toolStatus: 'done',
} satisfies Message,
},
})
await wrapper.find('.tool-line').trigger('click')
const expected = JSON.stringify(message, null, 2)
const code = wrapper.find('.tool-details code.hljs')
const displayed = JSON.parse(code.text())
expect(wrapper.find('.tool-details .code-lang').text()).toBe('json')
expect(wrapper.html()).toContain('chat.truncated')
expect(displayed.content).toContain('chat.truncated')
expect(code.findAll('span').length).toBeGreaterThan(0)
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
expect(writeText).toHaveBeenCalledWith(expected)
})
it('copies the full large JSON tool result even when the display is truncated', async () => {
const writeText = vi.mocked(navigator.clipboard.writeText)
const fullResult = {
content: 'x'.repeat(4000),
ok: true,
}
const wrapper = mount(MessageItem, {
props: {
message: {
id: 'tool-2',
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: 'read_file',
toolResult: JSON.stringify(fullResult),
toolStatus: 'done',
} satisfies Message,
},
})
await wrapper.find('.tool-line').trigger('click')
const code = wrapper.find('.tool-details code.hljs')
const displayed = JSON.parse(code.text())
expect(wrapper.find('.tool-details .code-lang').text()).toBe('json')
expect(wrapper.html()).toContain('chat.truncated')
expect(displayed.content).toContain('chat.truncated')
expect(code.findAll('span').length).toBeGreaterThan(0)
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
expect(writeText).toHaveBeenCalledWith(JSON.stringify(fullResult, null, 2))
})
it('truncates large JSON arrays at item boundaries so display remains parseable JSON', async () => {
const fullResult = Array.from({ length: 100 }, (_, index) => ({
index,
value: `item-${index}-${'x'.repeat(80)}`,
}))
const wrapper = mount(MessageItem, {
props: {
message: {
id: 'tool-array',
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: 'browser_snapshot',
toolResult: JSON.stringify(fullResult),
toolStatus: 'done',
} satisfies Message,
},
})
await wrapper.find('.tool-line').trigger('click')
const code = wrapper.find('.tool-details code.hljs')
const displayed = JSON.parse(code.text())
expect(Array.isArray(displayed)).toBe(true)
expect(displayed.at(-1)).toContain('chat.truncated')
expect(code.text().length).toBeLessThanOrEqual(1000)
})
it('copies the full large raw tool result even when the display is truncated', async () => {
const writeText = vi.mocked(navigator.clipboard.writeText)
const fullResult = 'line\n'.repeat(1200)
const wrapper = mount(MessageItem, {
props: {
message: {
id: 'tool-raw',
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: 'read_file',
toolResult: fullResult,
toolStatus: 'done',
} satisfies Message,
},
})
await wrapper.find('.tool-line').trigger('click')
const displayedResult = fullResult.slice(0, 1000) + '\nchat.truncated'
const code = wrapper.find('.tool-details code.hljs')
expect(wrapper.find('.tool-details .code-lang').text()).toBe('text')
expect(code.text()).toBe(displayedResult)
expect(code.findAll('span')).toHaveLength(0)
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
expect(writeText).toHaveBeenCalledWith(fullResult)
})
})
@@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, nextTick } from 'vue'
const mockScrollToBottom = vi.hoisted(() => vi.fn())
const mockScrollToMessage = vi.hoisted(() => vi.fn())
const mockScrollToAnchor = vi.hoisted(() => vi.fn())
const mockCaptureViewportPosition = vi.hoisted(() => vi.fn())
const mockRestoreViewportPosition = vi.hoisted(() => vi.fn())
const mockCaptureScrollPosition = vi.hoisted(() => vi.fn())
const mockRestoreScrollPosition = vi.hoisted(() => vi.fn())
const mockIsNearBottom = vi.hoisted(() => vi.fn(() => true))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/composables/useTheme', () => ({
useTheme: () => ({ isDark: false }),
}))
vi.mock('@/components/hermes/chat/VirtualMessageList.vue', () => ({
default: defineComponent({
name: 'VirtualMessageList',
props: {
messages: { type: Array, default: () => [] },
},
emits: ['top-reach'],
setup(_props, { expose }) {
expose({
isNearBottom: mockIsNearBottom,
scrollToBottom: mockScrollToBottom,
scrollToMessage: mockScrollToMessage,
scrollToAnchor: mockScrollToAnchor,
captureScrollPosition: mockCaptureScrollPosition,
restoreScrollPosition: mockRestoreScrollPosition,
captureViewportPosition: mockCaptureViewportPosition,
restoreViewportPosition: mockRestoreViewportPosition,
})
},
template: `
<div class="virtual-message-list-stub">
<slot name="item" v-for="message in messages" :key="message.id" :message="message" />
</div>
`,
}),
}))
vi.mock('@/components/hermes/chat/MessageItem.vue', () => ({
default: defineComponent({
name: 'MessageItem',
props: { message: { type: Object, required: true } },
template: '<div class="stub-message" :data-id="message.id">{{ message.content }}</div>',
}),
}))
import MessageList from '@/components/hermes/chat/MessageList.vue'
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
function makeMessage(id: string): Message {
return { id, role: 'user', content: id, timestamp: Date.now() }
}
function makeSession(id: string): Session {
return {
id,
title: id,
messages: [makeMessage(`${id}-message`)],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
async function flushSessionScroll() {
await nextTick()
await nextTick()
}
describe('MessageList session scroll position', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockIsNearBottom.mockReturnValue(true)
})
it('restores a previous session scroll position instead of forcing the bottom', async () => {
const chatStore = useChatStore()
chatStore.activeSessionId = 'scroll-session-a'
chatStore.activeSession = makeSession('scroll-session-a')
mount(MessageList, {
global: {
stubs: { Transition: false },
},
})
await flushSessionScroll()
vi.clearAllMocks()
const sessionASnapshot = {
scrollTop: 320,
scrollHeight: 1200,
clientHeight: 500,
wasNearBottom: false,
}
mockCaptureViewportPosition.mockReturnValue(sessionASnapshot)
chatStore.activeSessionId = 'scroll-session-b'
chatStore.activeSession = makeSession('scroll-session-b')
await flushSessionScroll()
expect(mockCaptureViewportPosition).toHaveBeenCalled()
vi.clearAllMocks()
mockCaptureViewportPosition.mockReturnValue({
scrollTop: 40,
scrollHeight: 1000,
clientHeight: 500,
wasNearBottom: false,
})
chatStore.activeSessionId = 'scroll-session-a'
chatStore.activeSession = makeSession('scroll-session-a')
await flushSessionScroll()
expect(mockRestoreViewportPosition).toHaveBeenCalledWith(sessionASnapshot)
expect(mockScrollToBottom).not.toHaveBeenCalled()
})
})
+95
View File
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mockSystemApi = vi.hoisted(() => ({
fetchAvailableModels: vi.fn(),
fetchAvailableModelsForProfile: vi.fn(),
updateDefaultModel: vi.fn(),
addCustomProvider: vi.fn(),
removeCustomProvider: vi.fn(),
}))
vi.mock('@/api/hermes/system', () => mockSystemApi)
vi.mock('@/api/client', () => ({ hasApiKey: () => true }))
import { useAppStore } from '@/stores/hermes/app'
import { useModelsStore } from '@/stores/hermes/models'
describe('Models Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
window.localStorage.clear()
})
it('keeps the sidebar model picker in sync after provider model visibility changes', async () => {
const visibleGroups = [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
available_models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
model_meta: {
'deepseek-v4-pro': { preview: true },
},
},
]
const availableModelsResponse = {
default: 'deepseek-v4-flash',
default_provider: 'deepseek',
groups: visibleGroups,
allProviders: visibleGroups,
model_visibility: {
deepseek: { mode: 'include', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] },
},
profiles: [
{
profile: 'default',
default: 'deepseek-v4-flash',
default_provider: 'deepseek',
groups: visibleGroups,
},
],
}
mockSystemApi.fetchAvailableModelsForProfile.mockResolvedValue(availableModelsResponse)
mockSystemApi.fetchAvailableModels.mockResolvedValue(availableModelsResponse)
mockSystemApi.addCustomProvider.mockResolvedValue(undefined)
const appStore = useAppStore()
appStore.modelGroups = [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-v4-flash'],
available_models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
},
]
const modelsStore = useModelsStore()
await modelsStore.addProvider({
name: 'deepseek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
model: 'deepseek-v4-flash',
})
expect(mockSystemApi.fetchAvailableModelsForProfile).toHaveBeenCalledWith('default')
expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalled()
expect(modelsStore.providers[0].models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
expect(appStore.modelGroups[0].models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
expect(appStore.modelGroups[0].available_models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
expect(appStore.modelGroups[0].model_meta).toEqual({
'deepseek-v4-pro': { preview: true },
})
expect(appStore.modelVisibility).toEqual({
deepseek: { mode: 'include', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] },
})
expect(appStore.selectedModel).toBe('deepseek-v4-flash')
expect(appStore.selectedProvider).toBe('deepseek')
})
})
+228
View File
@@ -0,0 +1,228 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
const mockProfilesApi = vi.hoisted(() => ({
fetchProfiles: vi.fn(),
fetchProfileDetail: vi.fn(),
createProfile: vi.fn(),
deleteProfile: vi.fn(),
renameProfile: vi.fn(),
switchProfile: vi.fn(),
exportProfile: vi.fn(),
importProfile: vi.fn(),
updateProfileAvatar: vi.fn(),
deleteProfileAvatar: vi.fn(),
}))
vi.mock('@/api/hermes/profiles', () => mockProfilesApi)
import { useProfilesStore } from '@/stores/hermes/profiles'
describe('Profiles Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('fetchProfiles loads profiles and sets active', async () => {
const profiles = [
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
]
mockProfilesApi.fetchProfiles.mockResolvedValue(profiles)
const store = useProfilesStore()
await store.fetchProfiles()
expect(store.profiles).toEqual(profiles)
expect(store.activeProfile?.name).toBe('default')
expect(store.loading).toBe(false)
})
it('fetchProfiles sets loading state', async () => {
mockProfilesApi.fetchProfiles.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve([]), 10))
)
const store = useProfilesStore()
const fetchPromise = store.fetchProfiles()
expect(store.loading).toBe(true)
await fetchPromise
expect(store.loading).toBe(false)
})
it('createProfile calls API and refreshes list', async () => {
mockProfilesApi.createProfile.mockResolvedValue({ success: true })
mockProfilesApi.fetchProfiles.mockResolvedValue([
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
{ name: 'new-profile', active: false, model: 'gpt-4', alias: '' },
])
const store = useProfilesStore()
const result = await store.createProfile('new-profile', false)
expect(result.success).toBe(true)
expect(mockProfilesApi.createProfile).toHaveBeenCalledWith('new-profile', false)
expect(store.profiles).toHaveLength(2)
})
it('deleteProfile clears detail cache', async () => {
mockProfilesApi.deleteProfile.mockResolvedValue(true)
mockProfilesApi.fetchProfiles.mockResolvedValue([
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
])
const store = useProfilesStore()
store.detailMap['test'] = { name: 'test', path: '/tmp/test', model: '', provider: '', skills: 0, hasEnv: false, hasSoulMd: false }
await store.deleteProfile('test')
expect(store.detailMap['test']).toBeUndefined()
expect(mockProfilesApi.deleteProfile).toHaveBeenCalledWith('test')
})
it('fetchProfileDetail uses cache', async () => {
const detail = { name: 'cached', path: '/tmp/cached', model: 'gpt-4', provider: 'openai', skills: 5, hasEnv: true, hasSoulMd: false }
const store = useProfilesStore()
store.detailMap['cached'] = detail
const result = await store.fetchProfileDetail('cached')
expect(result).toEqual(detail)
expect(mockProfilesApi.fetchProfileDetail).not.toHaveBeenCalled()
})
it('updateAvatar updates profile, detail cache, and active profile', async () => {
const savedAvatar = { type: 'image', dataUrl: 'data:image/png;base64,YQ==' }
mockProfilesApi.updateProfileAvatar.mockResolvedValue(savedAvatar)
const store = useProfilesStore()
store.profiles = [
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
]
store.activeProfile = store.profiles[0]
store.detailMap.default = { name: 'default', path: '/tmp/default', model: '', provider: '', skills: 0, hasEnv: false, hasSoulMd: false }
const result = await store.updateAvatar('default', { type: 'image', dataUrl: savedAvatar.dataUrl })
expect(result).toEqual(savedAvatar)
expect(mockProfilesApi.updateProfileAvatar).toHaveBeenCalledWith('default', { type: 'image', dataUrl: savedAvatar.dataUrl })
expect(store.profiles[0].avatar).toEqual(savedAvatar)
expect(store.activeProfile?.avatar).toEqual(savedAvatar)
expect(store.detailMap.default.avatar).toEqual(savedAvatar)
})
it('deleteAvatar clears avatar state', async () => {
mockProfilesApi.deleteProfileAvatar.mockResolvedValue(undefined)
const store = useProfilesStore()
store.profiles = [
{ name: 'default', active: true, model: 'gpt-4', alias: '', avatar: { type: 'generated', seed: 'old' } },
]
store.activeProfile = store.profiles[0]
store.detailMap.default = {
name: 'default',
path: '/tmp/default',
model: '',
provider: '',
skills: 0,
hasEnv: false,
hasSoulMd: false,
avatar: { type: 'generated', seed: 'old' },
}
await store.deleteAvatar('default')
expect(mockProfilesApi.deleteProfileAvatar).toHaveBeenCalledWith('default')
expect(store.profiles[0].avatar).toBeNull()
expect(store.activeProfile?.avatar).toBeNull()
expect(store.detailMap.default.avatar).toBeNull()
})
it('switchProfile sets switching state', async () => {
mockProfilesApi.switchProfile.mockResolvedValue(true)
mockProfilesApi.fetchProfiles.mockResolvedValue([])
const store = useProfilesStore()
const switchPromise = store.switchProfile('dev')
expect(store.switching).toBe(true)
await switchPromise
expect(store.switching).toBe(false)
})
it('switchProfile updates activeProfileName immediately', async () => {
mockProfilesApi.switchProfile.mockResolvedValue(true)
mockProfilesApi.fetchProfiles.mockResolvedValue([
{ name: 'default', active: false, model: 'gpt-4', alias: '' },
{ name: 'dev', active: true, model: 'gpt-4', alias: '' },
])
const store = useProfilesStore()
await store.switchProfile('dev')
// activeProfileName should be updated immediately
expect(store.activeProfileName).toBe('dev')
// localStorage should also be updated
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
})
it('switchProfile does not update state when API fails', async () => {
const initialName = 'default'
localStorage.setItem('hermes_active_profile_name', initialName)
mockProfilesApi.switchProfile.mockResolvedValue(false) // API failed
const store = useProfilesStore()
store.activeProfileName = initialName
const result = await store.switchProfile('dev')
// Should return false
expect(result).toBe(false)
// activeProfileName should NOT change
expect(store.activeProfileName).toBe(initialName)
// localStorage should NOT change
expect(localStorage.getItem('hermes_active_profile_name')).toBe(initialName)
})
it('switchProfile keeps activeProfileName even if fetchProfiles fails', async () => {
const initialName = 'default'
localStorage.setItem('hermes_active_profile_name', initialName)
mockProfilesApi.switchProfile.mockResolvedValue(true)
mockProfilesApi.fetchProfiles.mockRejectedValue(new Error('Network error'))
const store = useProfilesStore()
store.activeProfileName = initialName
const result = await store.switchProfile('dev')
// Should return true (API succeeded)
expect(result).toBe(true)
// activeProfileName should be updated even though fetchProfiles failed
expect(store.activeProfileName).toBe('dev')
// localStorage should be updated
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
})
it('switchProfile keeps the local selected profile independent of backend active flags', async () => {
const initialName = 'default'
localStorage.setItem('hermes_active_profile_name', initialName)
mockProfilesApi.switchProfile.mockResolvedValue(true)
mockProfilesApi.fetchProfiles.mockResolvedValue([
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
])
const store = useProfilesStore()
store.activeProfileName = initialName
const result = await store.switchProfile('dev')
expect(result).toBe(true)
expect(store.activeProfileName).toBe('dev')
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
})
})
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { normalizeCustomProviderBaseUrl } from '@/utils/providerBaseUrl'
describe('normalizeCustomProviderBaseUrl', () => {
it('normalizes api.apikey.fun custom provider URLs to the OpenAI-compatible v1 endpoint', () => {
expect(normalizeCustomProviderBaseUrl('https://api.apikey.fun')).toBe('https://api.apikey.fun/v1')
expect(normalizeCustomProviderBaseUrl('https://api.apikey.fun/')).toBe('https://api.apikey.fun/v1')
expect(normalizeCustomProviderBaseUrl('https://api.apikey.fun/anything')).toBe('https://api.apikey.fun/v1')
expect(normalizeCustomProviderBaseUrl(' https://api.apikey.fun/v2/chat ')).toBe('https://api.apikey.fun/v1')
})
it('leaves unrelated provider URLs unchanged apart from trimming', () => {
expect(normalizeCustomProviderBaseUrl(' https://api.example.com/v1 ')).toBe('https://api.example.com/v1')
expect(normalizeCustomProviderBaseUrl('https://not-api.apikey.fun/v1')).toBe('https://not-api.apikey.fun/v1')
})
})
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import RouteLinkItem from '@/components/common/RouteLinkItem.vue'
describe('RouteLinkItem', () => {
it('renders a real anchor with href from RouterLink custom slot', () => {
const wrapper = mount(RouteLinkItem, {
props: {
to: { name: 'hermes.session', params: { id: 's1' } },
active: true,
},
slots: {
default: 'Session S1',
},
global: {
components: {
RouterLink: defineComponent({
props: ['to', 'custom'],
template: '<slot href="/session/s1" :navigate="() => {}" :is-active="true" :is-exact-active="true" />',
}),
},
},
})
const link = wrapper.get('a')
expect(link.attributes('href')).toBe('/session/s1')
expect(link.classes()).toContain('route-link-item')
expect(link.classes()).toContain('active')
expect(link.attributes('aria-current')).toBe('page')
expect(link.text()).toContain('Session S1')
})
})
@@ -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)
})
})
+138
View File
@@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
vi.mock('@/stores/hermes/app', () => ({
useAppStore: () => ({
profileModelGroups: [],
displayModelName: (model: string) => model,
}),
}))
vi.mock('@/stores/hermes/profiles', () => ({
useProfilesStore: () => ({ profiles: [] }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/shared/session-display', () => ({
formatTimestampMs: () => 'now',
}))
vi.mock('naive-ui', () => ({
NPopconfirm: defineComponent({
name: 'NPopconfirm',
emits: ['positive-click'],
template: '<span><slot name="trigger" /><slot /></span>',
}),
NCheckbox: defineComponent({
name: 'NCheckbox',
props: ['checked'],
emits: ['click'],
template: '<input type="checkbox" :checked="checked" @click="$emit(\'click\')" />',
}),
NTooltip: defineComponent({
name: 'NTooltip',
template: '<span><slot name="trigger" /><slot /></span>',
}),
}))
const session = {
id: 's1',
title: 'Session One',
model: 'gpt-test',
provider: 'openai',
createdAt: Date.now(),
profile: 'kira',
}
describe('SessionListItem', () => {
it('renders normal mode as a link to the session route', () => {
const wrapper = mount(SessionListItem, {
props: {
session,
active: false,
pinned: false,
canDelete: true,
to: '/session/s1',
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
const link = wrapper.get('a.session-item')
expect(link.attributes('href')).toBe('/session/s1')
expect(wrapper.find('button.session-item').exists()).toBe(false)
})
it('renders selectable mode as a button and does not expose row href', () => {
const wrapper = mount(SessionListItem, {
props: {
session,
active: false,
pinned: false,
canDelete: true,
selectable: true,
selected: false,
to: '/session/s1',
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
expect(wrapper.find('button.session-item').exists()).toBe(true)
expect(wrapper.find('a.session-item').exists()).toBe(false)
})
it('does not select the row when clicking nested action controls', async () => {
const wrapper = mount(SessionListItem, {
props: {
session,
active: false,
pinned: false,
canDelete: true,
to: '/session/s1',
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
await wrapper.get('button.session-item-delete').trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
})
it('does not hijack modified clicks on normal links', async () => {
const wrapper = mount(SessionListItem, {
props: {
session,
active: false,
pinned: false,
canDelete: true,
to: '/session/s1',
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
const link = wrapper.get('a.session-item')
link.element.addEventListener('click', event => event.preventDefault())
await link.trigger('click', { ctrlKey: true })
expect(wrapper.emitted('select')).toBeUndefined()
})
})
+211
View File
@@ -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)
})
})
+90
View File
@@ -0,0 +1,90 @@
// @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 },
approvals: { mode: 'manual' },
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 () => {
let emittedValue: boolean | undefined
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,
NSwitch: {
props: ['value'],
emits: ['update:value'],
template: '<div class="n-switch" @click="$emit(\'update:value\', !value)"></div>',
setup(props: any, { emit }: any) {
return {
onClick: () => {
emittedValue = !props.value
emit('update:value', emittedValue)
},
}
},
},
},
},
})
expect(wrapper.text()).toContain('settings.session.liveMonitorHumanOnly')
const toggles = wrapper.findAll('.n-switch')
expect(toggles.length).toBe(2)
const humanOnlyToggle = toggles[1]
await humanOnlyToggle.trigger('click')
await Promise.resolve()
expect(mockPrefsStore.setHumanOnly).toHaveBeenCalledWith(false)
})
})
+185
View File
@@ -0,0 +1,185 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const openSessionSearchMock = vi.hoisted(() => vi.fn())
const mockAppStore = vi.hoisted(() => ({
sidebarOpen: true,
sidebarCollapsed: false,
connected: true,
serverVersion: 'test',
latestVersion: '',
updateAvailable: false,
clientOutdated: false,
updating: false,
toggleSidebar: vi.fn(),
toggleSidebarCollapsed: vi.fn(),
closeSidebar: vi.fn(),
doUpdate: vi.fn(),
reloadClient: vi.fn(),
}))
vi.mock('@/composables/useSessionSearch', () => ({
useSessionSearch: () => ({
openSessionSearch: openSessionSearchMock,
}),
}))
vi.mock('@/stores/hermes/app', () => ({
useAppStore: () => mockAppStore,
}))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<any>()
return {
...actual,
useRoute: () => ({ name: 'hermes.chat' }),
useRouter: () => ({ push: vi.fn(), hasRoute: () => true }),
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
createI18n: () => ({
global: { locale: { value: 'en' }, setLocaleMessage: vi.fn() },
}),
}))
vi.mock('@/composables/useTheme', () => ({
useTheme: () => ({ isDark: false }),
}))
vi.mock('/logo.png', () => ({
default: 'logo.png',
}))
vi.mock('@/components/layout/ProfileSelector.vue', () => ({
default: { name: 'ProfileSelector', template: '<div />' },
}))
vi.mock('@/components/layout/ModelSelector.vue', () => ({
default: { name: 'ModelSelector', template: '<div />' },
}))
vi.mock('@/components/layout/LanguageSwitch.vue', () => ({
default: { name: 'LanguageSwitch', template: '<div />' },
}))
vi.mock('@/components/layout/ThemeSwitch.vue', () => ({
default: { name: 'ThemeSwitch', template: '<div />' },
}))
vi.mock('@/components/common/RouteLinkItem.vue', () => ({
default: {
name: 'RouteLinkItem',
props: ['to', 'active'],
template: '<a class="route-link-item" :class="{ active }" href="#"><slot /></a>',
},
}))
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 v-bind="$attrs"><slot /></button>',
},
NSelect: {
template: '<div />',
},
}
})
import AppSidebar from '@/components/layout/AppSidebar.vue'
describe('AppSidebar search entry', () => {
beforeEach(() => {
openSessionSearchMock.mockClear()
mockAppStore.serverVersion = 'test'
mockAppStore.latestVersion = ''
mockAppStore.updateAvailable = false
mockAppStore.clientOutdated = false
mockAppStore.updating = false
mockAppStore.sidebarCollapsed = false
mockAppStore.reloadClient.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)
})
it('offers a client reload when the server version differs from the loaded bundle', async () => {
mockAppStore.clientOutdated = true
mockAppStore.serverVersion = '0.5.17'
const wrapper = mount(AppSidebar, {
global: {
stubs: {
ProfileSelector: true,
ModelSelector: true,
LanguageSwitch: true,
ThemeSwitch: true,
},
},
})
const reloadButton = wrapper.findAll('button')
.find(node => node.text().includes('sidebar.reloadClientVersion'))
expect(reloadButton).toBeTruthy()
await reloadButton!.trigger('click')
expect(mockAppStore.reloadClient).toHaveBeenCalledTimes(1)
})
it('uses short group labels and keeps group folding active when collapsed', async () => {
mockAppStore.sidebarCollapsed = true
const wrapper = mount(AppSidebar, {
global: {
stubs: {
ProfileSelector: true,
ModelSelector: true,
LanguageSwitch: true,
ThemeSwitch: true,
NButton: true,
},
},
})
expect(wrapper.classes()).toContain('collapsed')
expect(wrapper.findAll('.nav-group-label span').map(node => node.text())).toEqual([
'sidebar.groupConversationShort',
'sidebar.groupAgentShort',
'sidebar.groupMonitoringShort',
'sidebar.groupToolsShort',
'sidebar.groupSystemShort',
])
const agentGroup = wrapper.findAll('.nav-group')[1]
expect(agentGroup.find('.nav-group-items').attributes('style')).toBeUndefined()
await agentGroup.find('.nav-group-label').trigger('click')
expect(agentGroup.find('.nav-group-items').attributes('style')).toContain('display: none')
})
})
+51
View File
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import SkillList from '@/components/hermes/skills/SkillList.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/hermes/skills', () => ({
toggleSkill: vi.fn(),
}))
vi.mock('naive-ui', () => ({
NSwitch: defineComponent({
name: 'NSwitch',
props: ['value', 'loading'],
emits: ['update:value', 'click'],
template: '<button type="button" @click="$emit(\'click\')"></button>',
}),
useMessage: () => ({ error: vi.fn() }),
}))
describe('SkillList', () => {
it('supports filtering skills from external sources', () => {
const wrapper = mount(SkillList, {
props: {
categories: [
{
name: 'tools',
description: '',
skills: [
{ name: 'local-skill', description: 'Local skill', enabled: true, source: 'local' },
{ name: 'external-skill', description: 'External skill', enabled: true, source: 'external' },
],
},
],
archived: [],
selectedSkill: null,
searchQuery: '',
sourceFilter: 'external',
},
})
expect(wrapper.text()).toContain('external-skill')
expect(wrapper.text()).not.toContain('local-skill')
expect(wrapper.get('.source-dot').classes()).toContain('dot-external')
expect(wrapper.get('.source-dot').attributes('title')).toBe('skills.source.external')
})
})
+231
View File
@@ -0,0 +1,231 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
const fetchSkillUsageStatsMock = vi.hoisted(() => vi.fn())
const mockProfilesStore = vi.hoisted(() => ({
activeProfileName: 'default',
profiles: [{ name: 'default' }],
fetchProfiles: vi.fn(),
}))
vi.mock('@/api/hermes/skills', () => ({
fetchSkillUsageStats: fetchSkillUsageStatsMock,
}))
vi.mock('@/stores/hermes/profiles', () => ({
useProfilesStore: () => mockProfilesStore,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (key === 'skillsUsage.periodLabel') return `${params?.days}d`
if (key === 'skillsUsage.periodSummary') return `Last ${params?.days} days`
return key
},
}),
}))
vi.mock('naive-ui', async () => {
const actual = await vi.importActual<any>('naive-ui')
return {
...actual,
NButton: {
props: ['loading', 'type', 'size', 'quaternary', 'secondary'],
inheritAttrs: false,
template: '<button :data-type="type" :aria-pressed="$attrs[\'aria-pressed\']" @click="$emit(\'click\')"><slot /></button>',
},
}
})
import SkillsUsageView from '@/views/hermes/SkillsUsageView.vue'
const sevenDayStats = {
period_days: 7,
summary: {
total_skill_loads: 3,
total_skill_edits: 1,
total_skill_actions: 4,
distinct_skills_used: 2,
},
by_day: [
{
date: '2026-05-10',
view_count: 1,
manage_count: 0,
total_count: 1,
skills: [
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 },
],
},
{
date: '2026-05-11',
view_count: 2,
manage_count: 1,
total_count: 3,
skills: [
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 },
],
},
],
top_skills: [
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3, percentage: 75, last_used_at: 1_700_000_000 },
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1, percentage: 25, last_used_at: null },
],
}
describe('SkillsUsageView', () => {
beforeEach(() => {
fetchSkillUsageStatsMock.mockReset()
fetchSkillUsageStatsMock.mockResolvedValue(sevenDayStats)
mockProfilesStore.activeProfileName = 'default'
mockProfilesStore.profiles = [{ name: 'default' }]
mockProfilesStore.fetchProfiles.mockReset()
})
it('loads rolling 7 day skill usage and renders statistics beside a skill-colored visual trend', async () => {
const wrapper = mount(SkillsUsageView)
await flushPromises()
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(7)
expect(wrapper.text()).toContain('skillsUsage.title')
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
expect(wrapper.findAll('.skill-bar-col')).toHaveLength(2)
expect(wrapper.findAll('.skill-bar-segment[data-skill="hermes-agent"]')).toHaveLength(1)
expect(wrapper.findAll('.skill-bar-segment[data-skill="github-pr-workflow"]')).toHaveLength(1)
expect(wrapper.find('[data-testid="skills-usage-legend"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="skills-usage-stats"]').text()).toContain('4')
expect(wrapper.text()).toContain('hermes-agent')
expect(wrapper.text()).toContain('github-pr-workflow')
expect(wrapper.text()).toContain('75.0%')
})
it('reloads the selected period when the period button changes', async () => {
const wrapper = mount(SkillsUsageView)
await flushPromises()
fetchSkillUsageStatsMock.mockClear()
const thirtyDayButton = wrapper.findAll('button').find(button => button.text() === '30d')
expect(thirtyDayButton).toBeTruthy()
await thirtyDayButton!.trigger('click')
await flushPromises()
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(30)
expect(thirtyDayButton!.attributes('aria-pressed')).toBe('true')
})
it('flips the chart tooltip away from the hovered side of the bars', async () => {
const wrapper = mount(SkillsUsageView)
await flushPromises()
const bars = wrapper.findAll('.skill-bar-col')
expect(bars).toHaveLength(2)
await bars[1].trigger('mouseenter')
expect(wrapper.find('.floating-tooltip.align-left').exists()).toBe(true)
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-11')
await bars[0].trigger('mouseenter')
expect(wrapper.find('.floating-tooltip.align-right').exists()).toBe(true)
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-10')
})
it('keeps stale data visible while refreshing an already loaded period', async () => {
const wrapper = mount(SkillsUsageView)
await flushPromises()
let resolveRefresh!: (value: unknown) => void
fetchSkillUsageStatsMock.mockReturnValueOnce(new Promise(resolve => {
resolveRefresh = resolve
}))
const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh')
expect(refreshButton).toBeTruthy()
await refreshButton!.trigger('click')
expect(fetchSkillUsageStatsMock).toHaveBeenCalledTimes(2)
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
expect(wrapper.find('.usage-panel.is-refreshing').exists()).toBe(true)
resolveRefresh({
period_days: 7,
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
by_day: [
{
date: '2026-05-12',
view_count: 1,
manage_count: 0,
total_count: 1,
skills: [{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1 }],
},
],
top_skills: [
{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
],
})
await flushPromises()
expect(wrapper.text()).toContain('test-driven-development')
})
it('does not let an older refresh overwrite newer stats for the same period', async () => {
const wrapper = mount(SkillsUsageView)
await flushPromises()
let resolveOlder!: (value: unknown) => void
let resolveNewer!: (value: unknown) => void
fetchSkillUsageStatsMock
.mockReturnValueOnce(new Promise(resolve => { resolveOlder = resolve }))
.mockReturnValueOnce(new Promise(resolve => { resolveNewer = resolve }))
const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh')
expect(refreshButton).toBeTruthy()
await refreshButton!.trigger('click')
await refreshButton!.trigger('click')
resolveNewer({
period_days: 7,
summary: { total_skill_loads: 2, total_skill_edits: 0, total_skill_actions: 2, distinct_skills_used: 1 },
by_day: [
{
date: '2026-05-13',
view_count: 2,
manage_count: 0,
total_count: 2,
skills: [{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2 }],
},
],
top_skills: [
{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2, percentage: 100, last_used_at: null },
],
})
await flushPromises()
expect(wrapper.text()).toContain('newer-skill')
resolveOlder({
period_days: 7,
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
by_day: [
{
date: '2026-05-12',
view_count: 1,
manage_count: 0,
total_count: 1,
skills: [{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1 }],
},
],
top_skills: [
{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
],
})
await flushPromises()
expect(wrapper.text()).toContain('newer-skill')
expect(wrapper.text()).not.toContain('older-skill')
})
})
+185
View File
@@ -0,0 +1,185 @@
import { describe, it, expect } from 'vitest'
import { parseThinking, countThinkingChars, detectThinkingBoundary } from '@/utils/thinking-parser'
describe('parseThinking', () => {
it('splits a single closed <think> block from body', () => {
const r = parseThinking('<think>inner</think>body', { streaming: false })
expect(r.segments).toEqual(['inner'])
expect(r.body).toBe('body')
expect(r.pending).toBeNull()
expect(r.hasThinking).toBe(true)
})
it('collects multiple closed blocks in order', () => {
const r = parseThinking('<think>a</think>mid<thinking>b</thinking>end', { streaming: false })
expect(r.segments).toEqual(['a', 'b'])
expect(r.body).toBe('midend')
})
it('supports <thinking> and <reasoning> variants', () => {
const r = parseThinking('<reasoning>r</reasoning>body', { streaming: false })
expect(r.segments).toEqual(['r'])
expect(r.body).toBe('body')
})
it('is case-insensitive on tag names', () => {
const r = parseThinking('<Think>x</Think><REASONING>y</REASONING>z', { streaming: false })
expect(r.segments).toEqual(['x', 'y'])
expect(r.body).toBe('z')
})
it('returns hasThinking=false and body unchanged for plain text', () => {
const r = parseThinking('hello world', { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe('hello world')
expect(r.segments).toEqual([])
})
it('returns hasThinking=false for empty content', () => {
const r = parseThinking('', { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe('')
})
it('treats trailing unclosed tag as pending when streaming', () => {
const r = parseThinking('body<think>in-progress', { streaming: true })
expect(r.pending).toBe('in-progress')
expect(r.body).toBe('body')
expect(r.segments).toEqual([])
expect(r.hasThinking).toBe(true)
})
it('degrades trailing unclosed tag to body when NOT streaming (terminal state)', () => {
const r = parseThinking('body<think>orphan', { streaming: false })
expect(r.pending).toBeNull()
expect(r.body).toBe('body<think>orphan')
expect(r.segments).toEqual([])
expect(r.hasThinking).toBe(false)
})
it('combines closed segments with trailing pending (streaming)', () => {
const r = parseThinking('<think>done</think>mid<thinking>now', { streaming: true })
expect(r.segments).toEqual(['done'])
expect(r.pending).toBe('now')
expect(r.body).toBe('mid')
})
it('does NOT recognize <think> inside fenced code block', () => {
const src = 'before\n```\n<think>fake</think>\n```\nafter'
const r = parseThinking(src, { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe(src)
})
it('does NOT recognize <think> inside tilde-fenced code block', () => {
const src = '~~~\n<think>fake</think>\n~~~'
const r = parseThinking(src, { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe(src)
})
it('does NOT recognize <think> inside inline code', () => {
const src = 'the tag `<think>x</think>` is a literal'
const r = parseThinking(src, { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe(src)
})
it('parses real <think> outside code blocks even when code blocks contain fake ones', () => {
const src = '<think>real</think>text\n```\n<think>fake</think>\n```'
const r = parseThinking(src, { streaming: false })
expect(r.segments).toEqual(['real'])
expect(r.body).toBe('text\n```\n<think>fake</think>\n```')
})
it('does not leak code-protection placeholders for inline mentions of markdown fences', () => {
const src = [
'Previous fix kept the outer ` ```md ` block as code.',
'',
'````md',
'下面是可直接手动编辑的 PR draft。',
'```md',
'标题',
'```',
'````',
].join('\n')
const r = parseThinking(src, { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe(src)
expect(r.body).not.toContain('THKCODE')
expect(r.body).not.toContain('\u0000')
})
it('same-name nesting: inner tag absorbed into first segment (documented limitation)', () => {
const r = parseThinking('<think>a<think>b</think>c</think>', { streaming: false })
expect(r.segments).toEqual(['a<think>b'])
expect(r.body).toBe('c</think>')
})
it('handles chunk boundary: partial opening tag not yet identified', () => {
const mid = parseThinking('<thin', { streaming: true })
expect(mid.hasThinking).toBe(false)
expect(mid.body).toBe('<thin')
const after = parseThinking('<think>hi</think>done', { streaming: true })
expect(after.segments).toEqual(['hi'])
expect(after.body).toBe('done')
})
})
describe('countThinkingChars', () => {
it('counts all segments + pending as Unicode chars', () => {
const n = countThinkingChars({
segments: ['abc', '你好'],
pending: '🎉!',
body: '',
hasThinking: true,
})
expect(n).toBe(7)
})
it('returns 0 when no thinking', () => {
expect(countThinkingChars({ segments: [], pending: null, body: 'x', hasThinking: false })).toBe(0)
})
})
describe('detectThinkingBoundary', () => {
it('detects first appearance of opening tag', () => {
const r = detectThinkingBoundary('', '<think>x')
expect(r.startedAtBoundary).toBe(true)
expect(r.endedAtBoundary).toBe(false)
})
it('detects first appearance of closing tag', () => {
const r = detectThinkingBoundary('<think>hi', '<think>hi</think>')
expect(r.startedAtBoundary).toBe(false)
expect(r.endedAtBoundary).toBe(true)
})
it('detects both when both emerge in one delta', () => {
const r = detectThinkingBoundary('', '<think>x</think>')
expect(r.startedAtBoundary).toBe(true)
expect(r.endedAtBoundary).toBe(true)
})
it('reports no boundary when neither crossed', () => {
const r = detectThinkingBoundary('abc', 'abcdef')
expect(r.startedAtBoundary).toBe(false)
expect(r.endedAtBoundary).toBe(false)
})
it('ignores fake tags inside code blocks', () => {
const r = detectThinkingBoundary('', '```\n<think>fake</think>\n```')
expect(r.startedAtBoundary).toBe(false)
expect(r.endedAtBoundary).toBe(false)
})
it('is idempotent for repeated open/close after initial', () => {
const r = detectThinkingBoundary(
'<think>a</think><think>b',
'<think>a</think><think>b</think>',
)
expect(r.startedAtBoundary).toBe(false)
expect(r.endedAtBoundary).toBe(false)
})
})
+132
View File
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent } from 'vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/composables/useTheme', () => ({
useTheme: () => ({ isDark: false }),
}))
import MessageList from '@/components/hermes/chat/MessageList.vue'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
const MessageItemStub = defineComponent({
name: 'MessageItem',
props: {
message: { type: Object, required: true },
highlight: { type: Boolean, default: false },
},
template: '<div class="stub-message" :data-role="message.role" :data-id="message.id">{{ message.toolName || message.content }}</div>',
})
function makeSession(messages: Message[]): Session {
return {
id: 'session-1',
title: 'Tool trace visibility',
messages,
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
const sampleMessages: Message[] = [
{ id: 'user-1', role: 'user', content: 'inspect repo', timestamp: 1 },
{ id: 'tool-named', role: 'tool', content: '', timestamp: 2, toolName: 'read_file', toolResult: 'ok', toolStatus: 'done' },
{ id: 'tool-internal', role: 'tool', content: '', timestamp: 3, toolResult: 'internal', toolStatus: 'done' },
{ id: 'assistant-1', role: 'assistant', content: 'done', timestamp: 4 },
]
describe('tool trace visibility', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.removeItem('hermes_show_tool_calls')
useToolTraceVisibility().setToolTraceVisible(true)
})
function mountLiveList() {
const chatStore = useChatStore()
chatStore.activeSessionId = 'session-1'
chatStore.activeSession = makeSession(sampleMessages)
chatStore.abortState = { aborting: true, synced: false }
return mount(MessageList, {
global: {
stubs: {
MessageItem: MessageItemStub,
Transition: false,
},
},
})
}
it('shows named transcript and live tool traces by default while keeping unnamed internal tools hidden', () => {
const wrapper = mountLiveList()
expect(wrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
'user-1',
'tool-named',
'assistant-1',
])
expect(wrapper.findAll('.tool-call-name').map(node => node.text())).toContain('read_file')
})
it('applies the same default-visible rule to history sessions', () => {
const wrapper = mount(HistoryMessageList, {
props: { session: makeSession(sampleMessages) },
global: {
stubs: { MessageItem: MessageItemStub },
},
})
expect(wrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
'user-1',
'tool-named',
'assistant-1',
])
})
it('does not fall back to the live chat session while history session data is loading', () => {
const chatStore = useChatStore()
chatStore.activeSessionId = 'session-1'
chatStore.activeSession = makeSession(sampleMessages)
const wrapper = mount(HistoryMessageList, {
global: {
stubs: { MessageItem: MessageItemStub },
},
})
expect(wrapper.findAll('.stub-message')).toHaveLength(0)
})
it('hides named transcript traces when the toggle is off while keeping live tool stream visible', () => {
useToolTraceVisibility().setToolTraceVisible(false)
const liveWrapper = mountLiveList()
expect(liveWrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
'user-1',
'assistant-1',
])
expect(liveWrapper.findAll('.tool-call-name').map(node => node.text())).toContain('read_file')
const historyWrapper = mount(HistoryMessageList, {
props: { session: makeSession(sampleMessages) },
global: {
stubs: { MessageItem: MessageItemStub },
},
})
expect(historyWrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
'user-1',
'assistant-1',
])
})
})
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const mockUsageStore = vi.hoisted(() => ({
dailyUsage: [
{
date: '2026-05-12',
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 75,
cache_write_tokens: 10,
sessions: 2,
errors: 0,
cost: 0.02,
visualTokens: 225,
inputPercent: 44.444,
outputPercent: 22.222,
cachePercent: 33.333,
},
],
modelUsage: [
{
model: 'gpt-5',
inputTokens: 100,
outputTokens: 50,
cacheTokens: 75,
cacheWriteTokens: 10,
totalTokens: 150,
visualTokens: 225,
sessions: 2,
color: '#4fd1c5',
inputPercent: 44.444,
outputPercent: 22.222,
cachePercent: 33.333,
},
],
}))
vi.mock('@/stores/hermes/usage', () => ({
useUsageStore: () => mockUsageStore,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
import DailyTrend from '@/components/hermes/usage/DailyTrend.vue'
import ModelBreakdown from '@/components/hermes/usage/ModelBreakdown.vue'
describe('usage cache visualizations', () => {
it('renders cache-read as a visible segment in the daily usage bars', () => {
const wrapper = mount(DailyTrend)
const cacheSegment = wrapper.find('.bar-segment.cache')
expect(cacheSegment.exists()).toBe(true)
expect(cacheSegment.attributes('style')).toContain('height: 33.333%')
expect(wrapper.text()).toContain('usage.cacheRead')
})
it('renders model breakdown as input/output/cache stacked segments', () => {
const wrapper = mount(ModelBreakdown)
expect(wrapper.find('.model-swatch').attributes('style')).toContain('background: rgb(79, 209, 197)')
expect(wrapper.find('.model-bar-segment.input').exists()).toBe(true)
expect(wrapper.find('.model-bar-segment.output').exists()).toBe(true)
const cacheSegment = wrapper.find('.model-bar-segment.cache')
expect(cacheSegment.exists()).toBe(true)
expect(cacheSegment.attributes('style')).toContain('width: 33.333%')
})
})
+169
View File
@@ -0,0 +1,169 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const usageApiMock = vi.hoisted(() => ({
fetchUsageStats: vi.fn(),
}))
vi.mock('@/api/hermes/sessions', () => ({
fetchUsageStats: usageApiMock.fetchUsageStats,
}))
function emptyStats(totalSessions = 0, periodDays = 30) {
return {
total_input_tokens: totalSessions,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
total_cost: 0,
total_sessions: totalSessions,
period_days: periodDays,
model_usage: [],
daily_usage: [],
}
}
describe('usage store analytics adapter', () => {
beforeEach(() => {
setActivePinia(createPinia())
usageApiMock.fetchUsageStats.mockReset()
})
it('loads 30-day usage stats and derives chart metrics from the native-style payload', async () => {
usageApiMock.fetchUsageStats.mockResolvedValue({
total_input_tokens: 100,
total_output_tokens: 50,
total_cache_read_tokens: 25,
total_cache_write_tokens: 5,
total_reasoning_tokens: 10,
total_cost: 0.0123,
total_sessions: 2,
period_days: 30,
model_usage: [
{ model: 'gpt-5', input_tokens: 80, output_tokens: 40, cache_read_tokens: 20, cache_write_tokens: 3, reasoning_tokens: 7, sessions: 1 },
{ model: '', input_tokens: 20, output_tokens: 10, cache_read_tokens: 5, cache_write_tokens: 2, reasoning_tokens: 3, sessions: 1 },
],
daily_usage: [
{ date: '2026-04-29', input_tokens: 80, output_tokens: 20, cache_read_tokens: 40, cache_write_tokens: 4, sessions: 1, errors: 0, cost: 0.01 },
{ date: '2026-04-30', input_tokens: 30, output_tokens: 20, cache_read_tokens: 5, cache_write_tokens: 1, sessions: 1, errors: 0, cost: 0.0023 },
],
})
const { useUsageStore } = await import('@/stores/hermes/usage')
const store = useUsageStore()
await store.loadSessions()
expect(usageApiMock.fetchUsageStats).toHaveBeenCalledWith(30)
expect(store.totalTokens).toBe(150)
expect(store.cacheHitRate).toBeCloseTo(25 / 125 * 100)
expect(store.hasData).toBe(true)
expect(store.modelUsage).toHaveLength(2)
expect(store.modelUsage[0]).toMatchObject({
model: 'gpt-5',
totalTokens: 120,
inputTokens: 80,
outputTokens: 40,
cacheTokens: 20,
cacheWriteTokens: 3,
visualTokens: 140,
sessions: 1,
})
expect(store.modelUsage[0].color).toMatch(/^#[0-9a-f]{6}$/i)
expect(store.modelUsage[0].inputPercent).toBeCloseTo(80 / 140 * 100)
expect(store.modelUsage[0].outputPercent).toBeCloseTo(40 / 140 * 100)
expect(store.modelUsage[0].cachePercent).toBeCloseTo(20 / 140 * 100)
expect(store.modelUsage[1]).toMatchObject({
model: 'unknown',
totalTokens: 30,
inputTokens: 20,
outputTokens: 10,
cacheTokens: 5,
cacheWriteTokens: 2,
visualTokens: 35,
sessions: 1,
})
expect(store.modelUsage[1].color).toBe(store.getModelColor('unknown'))
expect(store.modelLegend.map(m => m.model)).toEqual(['gpt-5', 'unknown'])
expect(store.dailyUsage).toHaveLength(2)
expect(store.dailyUsage[0]).toMatchObject({
date: '2026-04-29',
input_tokens: 80,
output_tokens: 20,
cache_read_tokens: 40,
cache_write_tokens: 4,
visualTokens: 140,
sessions: 1,
cost: 0.01,
})
expect(store.dailyUsage[0].inputPercent).toBeCloseTo(80 / 140 * 100)
expect(store.dailyUsage[0].outputPercent).toBeCloseTo(20 / 140 * 100)
expect(store.dailyUsage[0].cachePercent).toBeCloseTo(40 / 140 * 100)
})
it('allows callers to request a different period', async () => {
usageApiMock.fetchUsageStats.mockResolvedValue(emptyStats())
const { useUsageStore } = await import('@/stores/hermes/usage')
const store = useUsageStore()
await store.loadSessions(7)
expect(usageApiMock.fetchUsageStats).toHaveBeenCalledWith(7)
expect(store.hasData).toBe(false)
})
it('keeps loading true when an older overlapping request resolves first', async () => {
let resolve30: (value: ReturnType<typeof emptyStats>) => void = () => {}
let resolve7: (value: ReturnType<typeof emptyStats>) => void = () => {}
usageApiMock.fetchUsageStats.mockImplementation((days: number) => new Promise(resolve => {
if (days === 30) resolve30 = resolve
if (days === 7) resolve7 = resolve
}))
const { useUsageStore } = await import('@/stores/hermes/usage')
const store = useUsageStore()
const firstLoad = store.loadSessions(30)
const secondLoad = store.loadSessions(7)
expect(store.isLoading).toBe(true)
resolve30(emptyStats(30, 30))
await firstLoad
expect(store.isLoading).toBe(true)
expect(store.stats).toBeNull()
resolve7(emptyStats(7, 7))
await secondLoad
expect(store.isLoading).toBe(false)
expect(store.stats?.period_days).toBe(7)
expect(store.totalSessions).toBe(7)
})
it('ignores stale overlapping responses that resolve after the selected period', async () => {
let resolve30: (value: ReturnType<typeof emptyStats>) => void = () => {}
let resolve7: (value: ReturnType<typeof emptyStats>) => void = () => {}
usageApiMock.fetchUsageStats.mockImplementation((days: number) => new Promise(resolve => {
if (days === 30) resolve30 = resolve
if (days === 7) resolve7 = resolve
}))
const { useUsageStore } = await import('@/stores/hermes/usage')
const store = useUsageStore()
const firstLoad = store.loadSessions(30)
const secondLoad = store.loadSessions(7)
resolve7(emptyStats(7, 7))
await secondLoad
expect(store.isLoading).toBe(false)
expect(store.stats?.period_days).toBe(7)
resolve30(emptyStats(30, 30))
await firstLoad
expect(store.stats?.period_days).toBe(7)
expect(store.totalSessions).toBe(7)
})
})
+94
View File
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
const mockUsageStore = vi.hoisted(() => ({
isLoading: false,
hasData: true,
loadSessions: vi.fn(),
}))
const mockProfilesStore = vi.hoisted(() => ({
activeProfileName: 'default',
profiles: [{ name: 'default' }],
fetchProfiles: vi.fn(),
}))
vi.mock('@/stores/hermes/usage', () => ({
useUsageStore: () => mockUsageStore,
}))
vi.mock('@/stores/hermes/profiles', () => ({
useProfilesStore: () => mockProfilesStore,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('naive-ui', () => ({
NButton: defineComponent({
name: 'NButton',
props: {
loading: Boolean,
type: String,
secondary: Boolean,
quaternary: Boolean,
size: String,
ariaPressed: [Boolean, String],
},
emits: ['click'],
template: '<button class="n-button-stub" :data-type="type" :aria-pressed="ariaPressed" @click="$emit(\'click\')"><slot /></button>',
}),
}))
vi.mock('@/components/hermes/usage/StatCards.vue', () => ({
default: defineComponent({ name: 'StatCards', template: '<section class="stat-cards-stub" />' }),
}))
vi.mock('@/components/hermes/usage/ModelBreakdown.vue', () => ({
default: defineComponent({ name: 'ModelBreakdown', template: '<section class="model-breakdown-stub" />' }),
}))
vi.mock('@/components/hermes/usage/DailyTrend.vue', () => ({
default: defineComponent({ name: 'DailyTrend', template: '<section class="daily-trend-stub" />' }),
}))
import UsageView from '@/views/hermes/UsageView.vue'
describe('UsageView period selector', () => {
beforeEach(() => {
mockUsageStore.isLoading = false
mockUsageStore.hasData = true
mockUsageStore.loadSessions.mockReset()
mockProfilesStore.activeProfileName = 'default'
mockProfilesStore.profiles = [{ name: 'default' }]
mockProfilesStore.fetchProfiles.mockReset()
})
it('loads the default 30-day period on mount', async () => {
mount(UsageView)
await flushPromises()
expect(mockUsageStore.loadSessions).toHaveBeenCalledWith(30)
})
it('lets users switch usage statistics between common dashboard periods', async () => {
const wrapper = mount(UsageView)
const periodButtons = wrapper.findAll('.period-option')
expect(periodButtons.map(button => button.text())).toEqual(['7d', '30d', '90d', '365d'])
expect(wrapper.find('.period-selector').attributes('role')).toBe('group')
await periodButtons[0].trigger('click')
expect(mockUsageStore.loadSessions).toHaveBeenLastCalledWith(7)
expect(periodButtons[0].attributes('data-type')).toBe('primary')
expect(periodButtons[0].attributes('aria-pressed')).toBe('true')
await wrapper.find('.refresh-button').trigger('click')
expect(mockUsageStore.loadSessions).toHaveBeenLastCalledWith(7)
})
})
@@ -0,0 +1,28 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { usePersistentRecord } from '@/composables/usePersistentRecord'
describe('usePersistentRecord', () => {
beforeEach(() => localStorage.clear())
it('loads saved record and persists updates', () => {
localStorage.setItem('hermes.sidebar.collapsedGroups', JSON.stringify({ agent: true }))
const state = usePersistentRecord('hermes.sidebar.collapsedGroups')
expect(state.record.agent).toBe(true)
state.record.system = true
state.persist()
expect(JSON.parse(localStorage.getItem('hermes.sidebar.collapsedGroups') || '{}')).toEqual({
agent: true,
system: true,
})
})
it('ignores invalid stored values', () => {
localStorage.setItem('hermes.sidebar.collapsedGroups', 'not-json')
const state = usePersistentRecord('hermes.sidebar.collapsedGroups')
expect({ ...state.record }).toEqual({})
})
})
+113
View File
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { useSpeech } from '@/composables/useSpeech'
class MockSpeechSynthesisUtterance {
text: string
rate = 1
pitch = 1
volume = 1
voice: SpeechSynthesisVoice | null = null
lang = ''
onboundary: ((event: SpeechSynthesisEvent) => void) | null = null
onend: (() => void) | null = null
onerror: (() => void) | null = null
constructor(text: string) {
this.text = text
}
}
describe('useSpeech WebSpeech playback', () => {
beforeEach(() => {
const voice = {
name: 'Google US English',
lang: 'en-US',
default: false,
localService: false,
voiceURI: 'Google US English',
} satisfies SpeechSynthesisVoice
const synth = {
speaking: false,
pending: false,
paused: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getVoices: vi.fn(() => [voice]),
speak: vi.fn(function (this: SpeechSynthesis) {
this.speaking = true
this.paused = false
}),
cancel: vi.fn(function (this: SpeechSynthesis) {
this.speaking = false
this.pending = false
this.paused = false
}),
pause: vi.fn(function (this: SpeechSynthesis) {
this.speaking = false
this.paused = true
}),
resume: vi.fn(function (this: SpeechSynthesis) {
this.speaking = true
this.paused = false
}),
} as unknown as SpeechSynthesis
Object.defineProperty(window, 'speechSynthesis', {
configurable: true,
value: synth,
})
Object.defineProperty(window, 'SpeechSynthesisUtterance', {
configurable: true,
value: MockSpeechSynthesisUtterance,
})
Object.defineProperty(globalThis, 'SpeechSynthesisUtterance', {
configurable: true,
value: MockSpeechSynthesisUtterance,
})
})
it('pauses and resumes the current browser voice instead of restarting it', () => {
const wrapper = mount(defineComponent({
setup() {
return {
speech: useSpeech(),
}
},
template: '<div />',
}))
const speech = wrapper.vm.speech
const synth = vi.mocked(window.speechSynthesis)
speech.toggleBrowser('message-1', 'Hello world', {
voiceName: 'Google US English',
})
expect(synth.speak).toHaveBeenCalledTimes(1)
expect(speech.isPlaying.value).toBe(true)
expect(speech.isPaused.value).toBe(false)
speech.toggleBrowser('message-1', 'Hello world', {
voiceName: 'Google US English',
})
expect(synth.pause).toHaveBeenCalledTimes(1)
expect(synth.speak).toHaveBeenCalledTimes(1)
expect(speech.isPlaying.value).toBe(true)
expect(speech.isPaused.value).toBe(true)
speech.toggleBrowser('message-1', 'Hello world', {
voiceName: 'Google US English',
})
expect(synth.resume).toHaveBeenCalledTimes(1)
expect(synth.speak).toHaveBeenCalledTimes(1)
expect(speech.isPaused.value).toBe(false)
wrapper.unmount()
})
})