hermes-agent/apps/desktop/src/hermes.test.ts
Brooklyn Nicholson 16aa09aca5 feat(mcp): first-class MCP tab — catalog, GUI auth/probe/logs, per-tool gating
A Cursor-style MCP manager inside Capabilities, plus the backend it needs.

- Server list with brand/favicon avatars + live status dot and a capability
  summary (N tools, M prompts, K resources); Servers | Catalog views.
- Catalog: one-click install of Nous-approved servers with required-env prompts.
- GUI OAuth: Authenticate opens the system browser from the TTY-less backend and
  verifies a token actually lands; header/API-key servers are never pushed down
  OAuth; a dirty mcp.json can't drop a freshly-persisted auth field.
- Full-width mcp.json editor (ecosystem document format) + pinned stdio/agent
  LogTail; probes cached 5m and keyed by (profile, config) so revisiting never
  respawns the fleet or shows a stale probe.
- Whole-map persistence (PUT /api/mcp/servers) so deletes/toggles actually stick
  (the generic /api/config deep-merge could not remove keys).
- perf: MCP probe/auth no longer hold the global skills lock, so a slow stdio
  spawn can't stall every other request into a 15s timeout.
- per-tool include/exclude gating (lib/mcp-tool-filter) mirroring the CLI loader.
2026-07-03 05:08:28 -05:00

137 lines
3.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getCronJobs,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfig,
getHermesConfigDefaults,
getProfiles,
getSessionMessages,
getStatus,
listAllProfileSessions,
listSessions
} from './hermes'
import { refreshActiveProfile } from './store/profile'
const emptySessionsResponse = {
limit: 0,
offset: 0,
sessions: [],
total: 0
}
describe('Hermes REST session helpers', () => {
let api: ReturnType<typeof vi.fn>
beforeEach(() => {
api = vi.fn().mockResolvedValue(emptySessionsResponse)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
})
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'hermesDesktop')
})
it('uses a longer timeout for the single-profile session list', async () => {
await listSessions(50, 1)
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent',
timeoutMs: 60_000
})
)
})
it('uses a longer timeout for the all-profile session list', async () => {
await listAllProfileSessions(50, 1)
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/profiles/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent&profile=all',
timeoutMs: 60_000
})
)
})
it('uses a longer timeout for profile listing during desktop startup', async () => {
api.mockResolvedValue({ profiles: [] })
await getProfiles()
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/profiles',
timeoutMs: 60_000
})
)
})
it('uses a longer timeout for active profile refresh during desktop startup', async () => {
api.mockResolvedValueOnce({ current: 'default' }).mockResolvedValueOnce({ profiles: [] })
await refreshActiveProfile()
expect(api).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
path: '/api/profiles/active',
timeoutMs: 60_000
})
)
expect(api).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
path: '/api/profiles',
timeoutMs: 60_000
})
)
})
it('gives the whole startup data burst the long timeout, not just profiles', async () => {
api.mockResolvedValue({})
const bootCalls: [() => Promise<unknown>, string][] = [
[getHermesConfig, '/api/config'],
[getHermesConfigDefaults, '/api/config/defaults'],
[getGlobalModelInfo, '/api/model/info'],
[() => getGlobalModelOptions(), '/api/model/options'],
[getCronJobs, '/api/cron/jobs']
]
for (const [call, path] of bootCalls) {
api.mockClear()
await call()
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path, timeoutMs: 60_000 }))
}
})
it('keeps the liveness poll on the short default so a dead backend fails fast', async () => {
api.mockResolvedValue({})
api.mockClear()
await getStatus()
// /api/status must NOT carry the long startup timeout — it is the runtime
// liveness probe and has to fail quickly when the backend drops.
const call = api.mock.calls[0]?.[0] as { path: string; timeoutMs?: number }
expect(call.path).toBe('/api/status')
expect(call.timeoutMs).toBeUndefined()
})
it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
api.mockResolvedValue({ messages: [], session_id: 'session-1' })
await getSessionMessages('session-1', 'xiaoxuxu')
expect(api).toHaveBeenCalledWith({
path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
profile: 'xiaoxuxu'
})
})
})