feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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('<tag>')
|
||||
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('<tag>')
|
||||
expect(html).toContain('class="code-lang">vue</span>')
|
||||
})
|
||||
})
|
||||
@@ -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('<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('<')
|
||||
})
|
||||
|
||||
it('escapes the language label', () => {
|
||||
const html = renderHighlightedCodeBlock('x'.repeat(5000), '<script>alert(1)</script>', 'Copy')
|
||||
|
||||
expect(html).toContain('<script>alert(1)</script>')
|
||||
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 <now>')
|
||||
expect(html).not.toContain('Copy <now>')
|
||||
})
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }) }],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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: '',
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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%')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user