Files
hermes-ui/tests/server/sessions-routes.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

318 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const listConversationsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'conversation-1' }] } })
const getConversationMessagesMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [] } })
const getConversationMessagesPaginatedMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [], pagination: {} } })
const listMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 's1' }] } })
const countMock = vi.fn(async (ctx: any) => { ctx.body = { count: 1 } })
const listHermesSessionsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'hermes-1' }] } })
const getHermesSessionMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
const importHermesSessionMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id } })
const searchMock = vi.fn(async (ctx: any) => { ctx.body = { results: [{ id: 'search-1' }] } })
const getMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
const getContextMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [] } })
const removeMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const renameMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const archiveMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const unarchiveMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const setWorkspaceMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const setModelMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const listWorkspaceFoldersMock = vi.fn(async (ctx: any) => { ctx.body = { folders: [] } })
const createWorkspaceFolderMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const renameWorkspaceFolderMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const deleteWorkspaceFolderMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const usageBatchMock = vi.fn(async (ctx: any) => { ctx.body = {} })
const usageSingleMock = vi.fn(async (ctx: any) => { ctx.body = { input_tokens: 0, output_tokens: 0 } })
const usageStatsMock = vi.fn(async (ctx: any) => { ctx.body = { total_input_tokens: 0, total_output_tokens: 0 } })
const contextLengthMock = vi.fn(async (ctx: any) => { ctx.body = { context_length: 256000 } })
const batchRemoveMock = vi.fn(async (ctx: any) => { ctx.body = { deleted: 1, failed: 0, errors: [] } })
const exportSessionMock = vi.fn(async (ctx: any) => { ctx.body = JSON.stringify({ id: ctx.params.id }) })
const listWorkspaceRunChangesMock = vi.fn(async (ctx: any) => { ctx.body = { changes: [] } })
const getWorkspaceRunChangeFileMock = vi.fn(async (ctx: any) => { ctx.body = { file: null } })
const listWorkspaceFilesMock = vi.fn(async (ctx: any) => { ctx.body = { entries: [], path: '' } })
const readWorkspaceFileMock = vi.fn(async (ctx: any) => { ctx.body = { content: '' } })
const writeWorkspaceFileMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const mkdirWorkspaceFileMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const deleteWorkspaceFileMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const renameWorkspaceFileMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const copyWorkspaceFileMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
vi.mock('../../packages/server/src/controllers/hermes/sessions', () => ({
listConversations: listConversationsMock,
getConversationMessages: getConversationMessagesMock,
getConversationMessagesPaginated: getConversationMessagesPaginatedMock,
list: listMock,
count: countMock,
listHermesSessions: listHermesSessionsMock,
getHermesSession: getHermesSessionMock,
importHermesSession: importHermesSessionMock,
search: searchMock,
get: getMock,
getContext: getContextMock,
remove: removeMock,
batchRemove: batchRemoveMock,
rename: renameMock,
archive: archiveMock,
unarchive: unarchiveMock,
setWorkspace: setWorkspaceMock,
setModel: setModelMock,
listWorkspaceFolders: listWorkspaceFoldersMock,
createWorkspaceFolder: createWorkspaceFolderMock,
renameWorkspaceFolder: renameWorkspaceFolderMock,
deleteWorkspaceFolder: deleteWorkspaceFolderMock,
usageBatch: usageBatchMock,
usageSingle: usageSingleMock,
usageStats: usageStatsMock,
contextLength: contextLengthMock,
exportSession: exportSessionMock,
listWorkspaceRunChanges: listWorkspaceRunChangesMock,
getWorkspaceRunChangeFile: getWorkspaceRunChangeFileMock,
listWorkspaceFiles: listWorkspaceFilesMock,
readWorkspaceFile: readWorkspaceFileMock,
writeWorkspaceFile: writeWorkspaceFileMock,
mkdirWorkspaceFile: mkdirWorkspaceFileMock,
deleteWorkspaceFile: deleteWorkspaceFileMock,
renameWorkspaceFile: renameWorkspaceFileMock,
copyWorkspaceFile: copyWorkspaceFileMock,
}))
describe('session routes', () => {
beforeEach(() => {
vi.resetModules()
listConversationsMock.mockClear()
getConversationMessagesMock.mockClear()
getConversationMessagesPaginatedMock.mockClear()
listMock.mockClear()
countMock.mockClear()
listHermesSessionsMock.mockClear()
getHermesSessionMock.mockClear()
importHermesSessionMock.mockClear()
searchMock.mockClear()
getMock.mockClear()
getContextMock.mockClear()
removeMock.mockClear()
renameMock.mockClear()
archiveMock.mockClear()
unarchiveMock.mockClear()
setModelMock.mockClear()
listWorkspaceFoldersMock.mockClear()
createWorkspaceFolderMock.mockClear()
renameWorkspaceFolderMock.mockClear()
deleteWorkspaceFolderMock.mockClear()
listWorkspaceRunChangesMock.mockClear()
getWorkspaceRunChangeFileMock.mockClear()
listWorkspaceFilesMock.mockClear()
readWorkspaceFileMock.mockClear()
writeWorkspaceFileMock.mockClear()
mkdirWorkspaceFileMock.mockClear()
deleteWorkspaceFileMock.mockClear()
renameWorkspaceFileMock.mockClear()
copyWorkspaceFileMock.mockClear()
})
it('registers conversations, session list, and search routes', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const paths = sessionRoutes.stack.map((entry: any) => entry.path)
expect(paths).toEqual(expect.arrayContaining([
'/api/hermes/sessions/conversations',
'/api/hermes/sessions/conversations/:id/messages',
'/api/hermes/sessions/conversations/:id/messages/paginated',
'/api/hermes/sessions',
'/api/hermes/sessions/count',
'/api/hermes/sessions/hermes',
'/api/hermes/sessions/hermes/:id',
'/api/hermes/sessions/hermes/:id/import',
'/api/hermes/search/sessions',
'/api/hermes/sessions/search',
'/api/hermes/sessions/usage',
'/api/hermes/usage/stats',
'/api/hermes/sessions/context-length',
'/api/hermes/sessions/:id/context',
'/api/hermes/sessions/:id/workspace-run-changes',
'/api/hermes/sessions/:id/workspace-run-changes/:changeId/files/:fileId',
'/api/hermes/sessions/:id/workspace-files/list',
'/api/hermes/sessions/:id/workspace-file/read',
'/api/hermes/sessions/:id/workspace-file/write',
'/api/hermes/sessions/:id/workspace-file/mkdir',
'/api/hermes/sessions/:id/workspace-file/delete',
'/api/hermes/sessions/:id/workspace-file/rename',
'/api/hermes/sessions/:id/workspace-file/copy',
'/api/hermes/sessions/:id',
'/api/hermes/sessions/:id/export',
'/api/hermes/sessions/:id/usage',
'/api/hermes/sessions/:id/rename',
'/api/hermes/sessions/:id/archive',
'/api/hermes/sessions/:id/unarchive',
'/api/hermes/sessions/:id/model',
'/api/hermes/workspace/folders',
'/api/hermes/workspace/folders/rename',
]))
})
it('delegates session count route before the session id route', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const countLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/count')
const idLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id')
expect(sessionRoutes.stack.indexOf(countLayer)).toBeLessThan(sessionRoutes.stack.indexOf(idLayer))
const ctx: any = { query: { source: 'cli' }, body: null, params: {} }
await countLayer.stack[0](ctx)
expect(countMock).toHaveBeenCalledWith(ctx)
expect(getMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ count: 1 })
})
it('delegates session context route to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/context')
const handler = layer.stack[0]
const ctx: any = { query: {}, body: null, params: { id: 'session-1' } }
await handler(ctx)
expect(getContextMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ session_id: 'session-1', messages: [] })
})
it('delegates workspace folder routes to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const listLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/workspace/folders' && entry.methods.includes('HEAD'))
const createLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/workspace/folders' && entry.methods.includes('POST'))
const renameLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/workspace/folders/rename')
const deleteLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/workspace/folders' && entry.methods.includes('DELETE'))
const listCtx: any = { query: {}, request: { body: {} }, body: null, params: {} }
await listLayer.stack[0](listCtx)
expect(listWorkspaceFoldersMock).toHaveBeenCalledWith(listCtx)
const createCtx: any = { query: {}, request: { body: { parentPath: '', name: 'new-folder' } }, body: null, params: {} }
await createLayer.stack[0](createCtx)
expect(createWorkspaceFolderMock).toHaveBeenCalledWith(createCtx)
const renameCtx: any = { query: {}, request: { body: { path: 'old-folder', name: 'new-folder' } }, body: null, params: {} }
await renameLayer.stack[0](renameCtx)
expect(renameWorkspaceFolderMock).toHaveBeenCalledWith(renameCtx)
const deleteCtx: any = { query: {}, request: { body: { path: 'new-folder' } }, body: null, params: {} }
await deleteLayer.stack[0](deleteCtx)
expect(deleteWorkspaceFolderMock).toHaveBeenCalledWith(deleteCtx)
})
it('delegates session workspace file routes to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const listLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/workspace-files/list')
const readLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/workspace-file/read')
const writeLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/workspace-file/write')
const mkdirLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/workspace-file/mkdir')
const deleteLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/workspace-file/delete')
const renameLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/workspace-file/rename')
const copyLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/workspace-file/copy')
const ctx: any = { query: {}, request: { body: {} }, body: null, params: { id: 'session-1' } }
await listLayer.stack[0](ctx)
await readLayer.stack[0](ctx)
await writeLayer.stack[0](ctx)
await mkdirLayer.stack[0](ctx)
await deleteLayer.stack[0](ctx)
await renameLayer.stack[0](ctx)
await copyLayer.stack[0](ctx)
expect(listWorkspaceFilesMock).toHaveBeenCalledWith(ctx)
expect(readWorkspaceFileMock).toHaveBeenCalledWith(ctx)
expect(writeWorkspaceFileMock).toHaveBeenCalledWith(ctx)
expect(mkdirWorkspaceFileMock).toHaveBeenCalledWith(ctx)
expect(deleteWorkspaceFileMock).toHaveBeenCalledWith(ctx)
expect(renameWorkspaceFileMock).toHaveBeenCalledWith(ctx)
expect(copyWorkspaceFileMock).toHaveBeenCalledWith(ctx)
})
it('delegates session search to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/search/sessions')
const handler = layer.stack[0]
const ctx: any = { query: { q: 'docker', limit: '8' }, body: null, params: {} }
await handler(ctx)
expect(searchMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ results: [{ id: 'search-1' }] })
})
it('keeps the legacy search path wired to the same controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/search')
const handler = layer.stack[0]
const ctx: any = { query: { q: 'docker' }, body: null, params: {} }
await handler(ctx)
expect(searchMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ results: [{ id: 'search-1' }] })
})
it('delegates conversations list and detail routes', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const listLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
const listCtx: any = { query: {}, body: null, params: {} }
await listLayer.stack[0](listCtx)
expect(listConversationsMock).toHaveBeenCalledWith(listCtx)
expect(listCtx.body).toEqual({ sessions: [{ id: 'conversation-1' }] })
const detailCtx: any = { params: { id: 'child-session' }, query: {}, body: null }
await detailLayer.stack[0](detailCtx)
expect(getConversationMessagesMock).toHaveBeenCalledWith(detailCtx)
expect(detailCtx.body).toEqual({ session_id: 'child-session', messages: [] })
})
it('delegates Hermes session import to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/hermes/:id/import')
const handler = layer.stack[0]
const ctx: any = { params: { id: 'hermes-abc' }, query: {}, request: { body: { profile: 'default' } }, body: null }
await handler(ctx)
expect(importHermesSessionMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ session_id: 'hermes-abc' })
})
it('delegates session export to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/export')
const handler = layer.stack[0]
const ctx: any = { params: { id: 'session-abc' }, query: {}, body: null, set: vi.fn() }
await handler(ctx)
expect(exportSessionMock).toHaveBeenCalledWith(ctx)
})
it('delegates session archive to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/archive')
const handler = layer.stack[0]
const ctx: any = { params: { id: 'session-abc' }, query: {}, body: null }
await handler(ctx)
expect(archiveMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ ok: true })
})
it('delegates session unarchive to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/unarchive')
const handler = layer.stack[0]
const ctx: any = { params: { id: 'session-abc' }, query: {}, body: null }
await handler(ctx)
expect(unarchiveMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ ok: true })
})
})