import { existsSync, readFileSync } from 'fs' import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' const agentBridgeMocks = vi.hoisted(() => ({ destroyAll: vi.fn(), destroyProfile: vi.fn(), })) const skillInjectorMocks = vi.hoisted(() => ({ injectMissingSkills: vi.fn(), resolveTargetDirForProfile: vi.fn(), })) const sessionDeleterMocks = vi.hoisted(() => ({ switchProfile: vi.fn(), })) // Mock hermes-cli vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ listProfiles: vi.fn(), getProfile: vi.fn(), createProfile: vi.fn(), deleteProfile: vi.fn(), renameProfile: vi.fn(), useProfile: vi.fn(), stopGateway: vi.fn(), startGateway: vi.fn(), startGatewayBackground: vi.fn(), setupReset: vi.fn(), exportProfile: vi.fn(), importProfile: vi.fn(), })) vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({ AgentBridgeClient: vi.fn(() => ({ destroyAll: agentBridgeMocks.destroyAll, destroyProfile: agentBridgeMocks.destroyProfile, })), })) vi.mock('../../packages/server/src/services/hermes/skill-injector', () => { const HermesSkillInjector = vi.fn(() => ({ injectMissingSkills: skillInjectorMocks.injectMissingSkills, })) as any HermesSkillInjector.resolveTargetDirForProfile = skillInjectorMocks.resolveTargetDirForProfile return { HermesSkillInjector } }) vi.mock('../../packages/server/src/services/hermes/session-deleter', () => ({ SessionDeleter: { getInstance: vi.fn(() => sessionDeleterMocks), }, })) import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli' describe('Profile Routes', () => { const originalHermesHome = process.env.HERMES_HOME const originalWebUiHome = process.env.HERMES_WEB_UI_HOME const tempHomes: string[] = [] beforeEach(() => { vi.clearAllMocks() agentBridgeMocks.destroyProfile.mockResolvedValue({ destroyed: 0 }) skillInjectorMocks.injectMissingSkills.mockResolvedValue({ targets: [] }) skillInjectorMocks.resolveTargetDirForProfile.mockImplementation((name: string) => join('/tmp/hermes-skills', name)) }) afterEach(async () => { if (originalHermesHome === undefined) delete process.env.HERMES_HOME else process.env.HERMES_HOME = originalHermesHome if (originalWebUiHome === undefined) delete process.env.HERMES_WEB_UI_HOME else process.env.HERMES_WEB_UI_HOME = originalWebUiHome await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) }) describe('hermes-cli wrapper', () => { it('listProfiles returns array', async () => { const mockProfiles = [{ name: 'default', active: true }] vi.mocked(hermesCli.listProfiles).mockResolvedValue(mockProfiles as any) const result = await hermesCli.listProfiles() expect(result).toEqual(mockProfiles) }) it('getProfile returns profile detail', async () => { const mockDetail = { name: 'default', path: '/tmp/default' } vi.mocked(hermesCli.getProfile).mockResolvedValue(mockDetail as any) const result = await hermesCli.getProfile('default') expect(result).toEqual(mockDetail) expect(hermesCli.getProfile).toHaveBeenCalledWith('default') }) it('createProfile calls CLI with name and clone flag', async () => { vi.mocked(hermesCli.createProfile).mockResolvedValue('Profile created') await hermesCli.createProfile('test', true) expect(hermesCli.createProfile).toHaveBeenCalledWith('test', true) }) it('deleteProfile calls CLI with name', async () => { vi.mocked(hermesCli.deleteProfile).mockResolvedValue(true) await hermesCli.deleteProfile('test') expect(hermesCli.deleteProfile).toHaveBeenCalledWith('test') }) it('renameProfile calls CLI with old and new name', async () => { vi.mocked(hermesCli.renameProfile).mockResolvedValue(true) await hermesCli.renameProfile('old', 'new') expect(hermesCli.renameProfile).toHaveBeenCalledWith('old', 'new') }) }) describe('profile deletion fallback', () => { it('removes a reserved profile directory when Hermes CLI refuses to delete it', async () => { const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-delete-')) tempHomes.push(hermesHome) process.env.HERMES_HOME = hermesHome const badProfileDir = join(hermesHome, 'profiles', 'hermes') await mkdir(badProfileDir, { recursive: true }) await writeFile(join(badProfileDir, 'config.yaml'), 'model:\n default: bad\n', 'utf-8') await writeFile(join(hermesHome, 'active_profile'), 'hermes\n', 'utf-8') vi.mocked(hermesCli.deleteProfile).mockResolvedValue(false) const { remove } = await import('../../packages/server/src/controllers/hermes/profiles') const ctx: any = { params: { name: 'hermes' }, status: 200, body: undefined } await remove(ctx) expect(ctx.status).toBe(200) expect(ctx.body).toEqual({ success: true, fallback: 'removed_reserved_profile_from_disk' }) expect(existsSync(badProfileDir)).toBe(false) expect(readFileSync(join(hermesHome, 'active_profile'), 'utf-8')).toBe('default\n') }) it('does not bypass Hermes CLI failures for normal profile names', async () => { const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-delete-')) tempHomes.push(hermesHome) process.env.HERMES_HOME = hermesHome const profileDir = join(hermesHome, 'profiles', 'work') await mkdir(profileDir, { recursive: true }) vi.mocked(hermesCli.deleteProfile).mockResolvedValue(false) const { remove } = await import('../../packages/server/src/controllers/hermes/profiles') const ctx: any = { params: { name: 'work' }, status: 200, body: undefined } await remove(ctx) expect(ctx.status).toBe(500) expect(ctx.body).toEqual({ error: 'Failed to delete profile' }) expect(existsSync(profileDir)).toBe(true) }) }) describe('Hermes CLI active profile switch', () => { it('only destroys bridge sessions for the target profile', async () => { const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-switch-')) tempHomes.push(hermesHome) process.env.HERMES_HOME = hermesHome const profileDir = join(hermesHome, 'profiles', 'work') await mkdir(profileDir, { recursive: true }) await writeFile(join(profileDir, 'config.yaml'), 'model:\n default: gpt-test\n', 'utf-8') await writeFile(join(hermesHome, 'active_profile'), 'work\n', 'utf-8') vi.mocked(hermesCli.useProfile).mockResolvedValue('Switched to work') vi.mocked(hermesCli.getProfile).mockResolvedValue({ name: 'work', path: profileDir, model: 'gpt-test', provider: 'test', skills: 0, hasEnv: false, hasSoulMd: false, } as any) agentBridgeMocks.destroyProfile.mockResolvedValue({ destroyed: 2 }) const { switchProfile } = await import('../../packages/server/src/controllers/hermes/profiles') const ctx: any = { request: { body: { name: 'work' } }, status: 200, body: undefined, } await switchProfile(ctx) expect(ctx.status).toBe(200) expect(ctx.body).toMatchObject({ success: true, active: 'work' }) expect(agentBridgeMocks.destroyProfile).toHaveBeenCalledWith('work') expect(agentBridgeMocks.destroyAll).not.toHaveBeenCalled() expect(sessionDeleterMocks.switchProfile).toHaveBeenCalledWith('work') }) }) describe('profile avatars', () => { it('stores generated avatar metadata under the Web UI home', async () => { const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-')) tempHomes.push(webUiHome) process.env.HERMES_WEB_UI_HOME = webUiHome const { updateAvatar } = await import('../../packages/server/src/controllers/hermes/profiles') const ctx: any = { params: { name: 'work' }, request: { body: { type: 'generated', seed: 'custom-seed' } }, status: 200, body: undefined, } await updateAvatar(ctx) const metaPath = join(webUiHome, 'profile-metadata', Buffer.from('work', 'utf-8').toString('base64url'), 'avatar.json') expect(ctx.status).toBe(200) expect(ctx.body.avatar).toMatchObject({ type: 'generated', seed: 'custom-seed' }) expect(JSON.parse(readFileSync(metaPath, 'utf-8'))).toMatchObject({ type: 'generated', seed: 'custom-seed', }) }) it('stores uploaded image avatars and returns a data URL', async () => { const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-')) tempHomes.push(webUiHome) process.env.HERMES_WEB_UI_HOME = webUiHome const dataUrl = `data:image/png;base64,${Buffer.from('avatar-png').toString('base64')}` const { updateAvatar } = await import('../../packages/server/src/controllers/hermes/profiles') const ctx: any = { params: { name: 'work' }, request: { body: { type: 'image', dataUrl } }, status: 200, body: undefined, } await updateAvatar(ctx) const dir = join(webUiHome, 'profile-metadata', Buffer.from('work', 'utf-8').toString('base64url')) const meta = JSON.parse(readFileSync(join(dir, 'avatar.json'), 'utf-8')) expect(ctx.status).toBe(200) expect(ctx.body.avatar).toMatchObject({ type: 'image', dataUrl }) expect(meta).toMatchObject({ type: 'image', file: 'avatar.bin', mime: 'image/png' }) expect(readFileSync(join(dir, 'avatar.bin')).toString()).toBe('avatar-png') }) it('deletes profile avatar metadata', async () => { const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-')) tempHomes.push(webUiHome) process.env.HERMES_WEB_UI_HOME = webUiHome const metadataDir = join(webUiHome, 'profile-metadata', Buffer.from('work', 'utf-8').toString('base64url')) await mkdir(metadataDir, { recursive: true }) await writeFile(join(metadataDir, 'avatar.json'), '{"type":"generated"}\n', 'utf-8') const { deleteAvatar } = await import('../../packages/server/src/controllers/hermes/profiles') const ctx: any = { params: { name: 'work' }, status: 200, body: undefined } await deleteAvatar(ctx) expect(ctx.status).toBe(200) expect(ctx.body).toEqual({ success: true }) expect(existsSync(metadataDir)).toBe(false) }) }) })