mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-10 02:30:15 +00:00
525 lines
22 KiB
TypeScript
525 lines
22 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { Readable } from 'stream'
|
|
|
|
const mockGetSkillUsageStatsFromDb = vi.hoisted(() => vi.fn())
|
|
const mockGetActiveProfileName = vi.hoisted(() => vi.fn())
|
|
const mockGetProfileDir = vi.hoisted(() => vi.fn())
|
|
const mockUpdateConfigYamlForProfile = vi.hoisted(() => vi.fn())
|
|
const mockReadConfigYamlForProfile = vi.hoisted(() => vi.fn())
|
|
const mockSafeReadFile = vi.hoisted(() => vi.fn())
|
|
const mockExtractDescription = vi.hoisted(() => vi.fn())
|
|
const mockListFilesRecursive = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
|
getSkillUsageStatsFromDb: mockGetSkillUsageStatsFromDb,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
|
getActiveProfileName: mockGetActiveProfileName,
|
|
getProfileDir: mockGetProfileDir,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
|
readConfigYamlForProfile: mockReadConfigYamlForProfile,
|
|
updateConfigYamlForProfile: mockUpdateConfigYamlForProfile,
|
|
safeReadFile: mockSafeReadFile,
|
|
extractDescription: mockExtractDescription,
|
|
listFilesRecursive: mockListFilesRecursive,
|
|
}))
|
|
|
|
async function loadController() {
|
|
vi.resetModules()
|
|
return import('../../packages/server/src/controllers/hermes/skills')
|
|
}
|
|
|
|
function multipartBody(boundary: string, parts: Array<{ name: string; value: string; filename?: string; filenameStar?: string; contentType?: string }>): Buffer {
|
|
const chunks: Buffer[] = []
|
|
for (const part of parts) {
|
|
chunks.push(Buffer.from(`--${boundary}\r\n`))
|
|
const filename = part.filenameStar
|
|
? `; filename*=UTF-8''${part.filenameStar}`
|
|
: part.filename
|
|
? `; filename="${part.filename}"`
|
|
: ''
|
|
chunks.push(Buffer.from(`Content-Disposition: form-data; name="${part.name}"${filename}\r\n`))
|
|
if (part.contentType) chunks.push(Buffer.from(`Content-Type: ${part.contentType}\r\n`))
|
|
chunks.push(Buffer.from('\r\n'))
|
|
chunks.push(Buffer.from(part.value))
|
|
chunks.push(Buffer.from('\r\n'))
|
|
}
|
|
chunks.push(Buffer.from(`--${boundary}--\r\n`))
|
|
return Buffer.concat(chunks)
|
|
}
|
|
|
|
describe('skills controller', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockGetActiveProfileName.mockReturnValue('default')
|
|
mockGetProfileDir.mockImplementation((profile: string) => `/tmp/hermes-${profile}`)
|
|
mockReadConfigYamlForProfile.mockResolvedValue({})
|
|
mockSafeReadFile.mockImplementation(async (path: string) => {
|
|
try {
|
|
return await readFile(path, 'utf-8')
|
|
} catch {
|
|
return null
|
|
}
|
|
})
|
|
mockExtractDescription.mockImplementation((content: string) => {
|
|
return content.split('\n').find(line => line.trim() && !line.startsWith('#'))?.trim() || ''
|
|
})
|
|
mockListFilesRecursive.mockResolvedValue([])
|
|
mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record<string, any>) => Record<string, any>) => updater({}))
|
|
mockGetSkillUsageStatsFromDb.mockResolvedValue({
|
|
period_days: 7,
|
|
summary: {
|
|
total_skill_loads: 0,
|
|
total_skill_edits: 0,
|
|
total_skill_actions: 0,
|
|
distinct_skills_used: 0,
|
|
},
|
|
by_day: [],
|
|
top_skills: [],
|
|
})
|
|
})
|
|
|
|
it('loads skill usage from the request-scoped profile state database', async () => {
|
|
const { usageStats } = await loadController()
|
|
const ctx: any = { query: { days: '30' }, state: { profile: { name: 'research' } }, body: null }
|
|
|
|
await usageStats(ctx)
|
|
|
|
expect(mockGetSkillUsageStatsFromDb).toHaveBeenCalledWith(30, undefined, 'research')
|
|
expect(ctx.body.period_days).toBe(7)
|
|
})
|
|
|
|
it('falls back to active profile when no request profile is set', async () => {
|
|
mockGetActiveProfileName.mockReturnValue('travel')
|
|
const { usageStats } = await loadController()
|
|
const ctx: any = { query: {}, state: {}, body: null }
|
|
|
|
await usageStats(ctx)
|
|
|
|
expect(mockGetSkillUsageStatsFromDb).toHaveBeenCalledWith(7, undefined, 'travel')
|
|
})
|
|
|
|
it('toggles skills in the request-scoped profile config', async () => {
|
|
let updatedConfig: Record<string, any> | undefined
|
|
mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record<string, any>) => Record<string, any>) => {
|
|
updatedConfig = await updater({ skills: { disabled: ['old-skill'] }, model: { default: 'glm-5.1' } })
|
|
return undefined
|
|
})
|
|
const { toggle } = await loadController()
|
|
const ctx: any = {
|
|
request: { body: { name: 'new-skill', enabled: false } },
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
await toggle(ctx)
|
|
|
|
expect(mockUpdateConfigYamlForProfile).toHaveBeenCalledWith('research', expect.any(Function))
|
|
expect(updatedConfig).toEqual({
|
|
skills: { disabled: ['old-skill', 'new-skill'] },
|
|
model: { default: 'glm-5.1' },
|
|
})
|
|
expect(ctx.body).toEqual({ success: true })
|
|
})
|
|
|
|
it('lists configured external skill directories with external source while keeping local skills first', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-external-skills-'))
|
|
const profileDir = join(root, 'profile')
|
|
const localSkillDir = join(profileDir, 'skills', 'tools', 'dupe-skill')
|
|
const externalDir = join(root, 'external-skills')
|
|
const externalSkillDir = join(externalDir, 'tools', 'external-skill')
|
|
const externalDupeDir = join(externalDir, 'tools', 'dupe-skill')
|
|
|
|
await mkdir(localSkillDir, { recursive: true })
|
|
await mkdir(externalSkillDir, { recursive: true })
|
|
await mkdir(externalDupeDir, { recursive: true })
|
|
await writeFile(join(localSkillDir, 'SKILL.md'), '# Local Dupe\nlocal copy\n', 'utf-8')
|
|
await writeFile(join(externalSkillDir, 'SKILL.md'), '# External Skill\nexternal copy\n', 'utf-8')
|
|
await writeFile(join(externalDupeDir, 'SKILL.md'), '# External Dupe\nexternal duplicate\n', 'utf-8')
|
|
|
|
mockGetProfileDir.mockReturnValue(profileDir)
|
|
mockReadConfigYamlForProfile.mockResolvedValue({
|
|
skills: { external_dirs: [externalDir] },
|
|
})
|
|
|
|
try {
|
|
const { list } = await loadController()
|
|
const ctx: any = { state: { profile: { name: 'research' } }, body: null }
|
|
|
|
await list(ctx)
|
|
|
|
const tools = ctx.body.categories.find((category: any) => category.name === 'tools')
|
|
expect(tools.skills).toEqual([
|
|
expect.objectContaining({ name: 'dupe-skill', source: 'local', description: 'local copy' }),
|
|
expect.objectContaining({ name: 'external-skill', source: 'external', description: 'external copy' }),
|
|
])
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('lists flat symlinked skills in the misc category', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-symlink-flat-skill-'))
|
|
const profileDir = join(root, 'profile')
|
|
const profileSkillsDir = join(profileDir, 'skills')
|
|
const sharedSkillDir = join(root, 'shared-skills', 'linked-flat-skill')
|
|
|
|
await mkdir(profileSkillsDir, { recursive: true })
|
|
await mkdir(sharedSkillDir, { recursive: true })
|
|
await writeFile(join(sharedSkillDir, 'SKILL.md'), '# Linked Flat Skill\nflat symlink copy\n', 'utf-8')
|
|
await symlink(sharedSkillDir, join(profileSkillsDir, 'linked-flat-skill'))
|
|
|
|
mockGetProfileDir.mockReturnValue(profileDir)
|
|
|
|
try {
|
|
const { list } = await loadController()
|
|
const ctx: any = { state: { profile: { name: 'research' } }, body: null }
|
|
|
|
await list(ctx)
|
|
|
|
expect(ctx.body.categories).toContainEqual(expect.objectContaining({
|
|
name: 'misc',
|
|
skills: [
|
|
expect.objectContaining({
|
|
name: 'linked-flat-skill',
|
|
source: 'local',
|
|
description: 'flat symlink copy',
|
|
}),
|
|
],
|
|
}))
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('lists Codex user and system skills for the codex target', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-codex-skills-'))
|
|
const previousHome = process.env.HOME
|
|
const userSkillDir = join(root, '.agents', 'skills', 'user-skill')
|
|
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'system-skill')
|
|
|
|
await mkdir(userSkillDir, { recursive: true })
|
|
await mkdir(systemSkillDir, { recursive: true })
|
|
await writeFile(join(userSkillDir, 'SKILL.md'), '# User Skill\nuser codex skill\n', 'utf-8')
|
|
await writeFile(join(systemSkillDir, 'SKILL.md'), '# System Skill\nsystem codex skill\n', 'utf-8')
|
|
process.env.HOME = root
|
|
|
|
try {
|
|
const { list } = await loadController()
|
|
const ctx: any = { query: { target: 'codex' }, state: { profile: { name: 'research' } }, body: null }
|
|
|
|
await list(ctx)
|
|
|
|
const misc = ctx.body.categories.find((category: any) => category.name === 'misc')
|
|
expect(misc.skills).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ name: 'user-skill', source: 'local', description: 'user codex skill' }),
|
|
expect.objectContaining({ name: 'system-skill', source: 'builtin', description: 'system codex skill' }),
|
|
]))
|
|
expect(ctx.body.paths).toEqual({
|
|
local: join(root, '.agents', 'skills'),
|
|
external: [join(root, '.codex', 'skills', '.system')],
|
|
})
|
|
expect(mockReadConfigYamlForProfile).not.toHaveBeenCalled()
|
|
} finally {
|
|
if (previousHome == null) delete process.env.HOME
|
|
else process.env.HOME = previousHome
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('reads Codex system skill details for the codex target', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-codex-system-skill-'))
|
|
const previousHome = process.env.HOME
|
|
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'imagegen')
|
|
|
|
await mkdir(join(systemSkillDir, 'references'), { recursive: true })
|
|
await writeFile(join(systemSkillDir, 'SKILL.md'), '# Imagegen\nsystem image skill\n', 'utf-8')
|
|
await writeFile(join(systemSkillDir, 'references', 'usage.md'), 'usage notes\n', 'utf-8')
|
|
process.env.HOME = root
|
|
mockListFilesRecursive.mockResolvedValue([
|
|
{ path: 'SKILL.md', isDir: false },
|
|
{ path: 'references/usage.md', isDir: false },
|
|
])
|
|
|
|
try {
|
|
const { readFile_, listFiles } = await loadController()
|
|
const readCtx: any = {
|
|
query: { target: 'codex' },
|
|
params: { path: 'misc/imagegen/SKILL.md' },
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
await readFile_(readCtx)
|
|
|
|
expect(readCtx.body).toEqual({ content: '# Imagegen\nsystem image skill\n' })
|
|
|
|
const filesCtx: any = {
|
|
query: { target: 'codex' },
|
|
params: { category: 'misc', skill: 'imagegen' },
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
await listFiles(filesCtx)
|
|
|
|
expect(filesCtx.body).toEqual({ files: [{ path: 'references/usage.md', isDir: false }] })
|
|
} finally {
|
|
if (previousHome == null) delete process.env.HOME
|
|
else process.env.HOME = previousHome
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('traverses symlinked category entries without following hidden or cyclic links', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-symlink-category-skill-'))
|
|
const profileDir = join(root, 'profile')
|
|
const toolsDir = join(profileDir, 'skills', 'tools')
|
|
const linkedSkillDir = join(root, 'shared-skills', 'linked-skill')
|
|
const linkedCategoryDir = join(root, 'shared-skills', 'linked-category')
|
|
|
|
await mkdir(toolsDir, { recursive: true })
|
|
await mkdir(linkedSkillDir, { recursive: true })
|
|
await mkdir(linkedCategoryDir, { recursive: true })
|
|
await writeFile(join(toolsDir, 'DESCRIPTION.md'), '# Tools\n', 'utf-8')
|
|
await writeFile(join(linkedSkillDir, 'SKILL.md'), '# Linked Skill\nlinked skill copy\n', 'utf-8')
|
|
await writeFile(join(linkedCategoryDir, 'SKILL.md'), '# Linked Category Skill\nlinked category copy\n', 'utf-8')
|
|
await symlink(linkedSkillDir, join(toolsDir, 'linked-skill'))
|
|
await symlink(linkedCategoryDir, join(toolsDir, 'linked-group'))
|
|
await symlink(toolsDir, join(toolsDir, 'loop'))
|
|
await symlink(linkedSkillDir, join(toolsDir, '.hidden-skill'))
|
|
|
|
mockGetProfileDir.mockReturnValue(profileDir)
|
|
|
|
try {
|
|
const { list } = await loadController()
|
|
const ctx: any = { state: { profile: { name: 'research' } }, body: null }
|
|
|
|
await list(ctx)
|
|
|
|
const tools = ctx.body.categories.find((category: any) => category.name === 'tools')
|
|
expect(tools.skills).toEqual([
|
|
expect.objectContaining({ name: 'linked-group', description: 'linked category copy', source: 'local' }),
|
|
expect.objectContaining({ name: 'linked-skill', description: 'linked skill copy', source: 'local' }),
|
|
])
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('updates external skill directories in the request-scoped profile config', async () => {
|
|
let updatedConfig: Record<string, any> | undefined
|
|
mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record<string, any>) => Record<string, any>) => {
|
|
updatedConfig = await updater({ skills: { disabled: ['old-skill'] }, model: { default: 'glm-5.1' } })
|
|
return undefined
|
|
})
|
|
const { updateExternalDirs } = await loadController()
|
|
const ctx: any = {
|
|
request: { body: { dirs: [' ~/research-skills ', '', '~/research-skills', '$HOME/shared-skills'] } },
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
await updateExternalDirs(ctx)
|
|
|
|
expect(mockUpdateConfigYamlForProfile).toHaveBeenCalledWith('research', expect.any(Function))
|
|
expect(updatedConfig).toEqual({
|
|
skills: { disabled: ['old-skill'], external_dirs: ['~/research-skills', '$HOME/shared-skills'] },
|
|
model: { default: 'glm-5.1' },
|
|
})
|
|
expect(ctx.body).toEqual({ success: true, dirs: ['~/research-skills', '$HOME/shared-skills'] })
|
|
})
|
|
|
|
it('imports skills into the request-scoped profile directory', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-import-profile-'))
|
|
const defaultProfileDir = join(root, 'default')
|
|
const researchProfileDir = join(root, 'research')
|
|
mockGetProfileDir.mockImplementation((profile: string) => profile === 'research' ? researchProfileDir : defaultProfileDir)
|
|
|
|
const boundary = '----hermes-skill-import-test'
|
|
const ctx: any = {
|
|
get: vi.fn((header: string) => header.toLowerCase() === 'content-type' ? `multipart/form-data; boundary=${boundary}` : ''),
|
|
req: Readable.from([multipartBody(boundary, [
|
|
{ name: 'file', filename: 'demo-skill/SKILL.md', contentType: 'text/markdown', value: '# Demo Skill\nresearch copy\n' },
|
|
])]),
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
try {
|
|
const { importSkill } = await loadController()
|
|
|
|
await importSkill(ctx)
|
|
|
|
await expect(readFile(join(researchProfileDir, 'skills', 'demo-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# Demo Skill\nresearch copy\n')
|
|
await expect(readFile(join(defaultProfileDir, 'skills', 'demo-skill', 'SKILL.md'), 'utf-8')).rejects.toThrow()
|
|
expect(ctx.body).toEqual({ success: true, name: 'demo-skill' })
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('returns bad request for malformed encoded skill import filenames', async () => {
|
|
const boundary = '----hermes-skill-import-bad-filename'
|
|
const ctx: any = {
|
|
get: vi.fn((header: string) => header.toLowerCase() === 'content-type' ? `multipart/form-data; boundary=${boundary}` : ''),
|
|
req: Readable.from([multipartBody(boundary, [
|
|
{ name: 'file', filenameStar: '%E0%A4%A', contentType: 'text/markdown', value: '# Demo Skill\n' },
|
|
])]),
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
const { importSkill } = await loadController()
|
|
|
|
await importSkill(ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body).toEqual({ error: 'Invalid multipart filename encoding' })
|
|
})
|
|
|
|
it('imports skills with valid encoded multipart filenames', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-import-encoded-filename-'))
|
|
const profileDir = join(root, 'research')
|
|
mockGetProfileDir.mockReturnValue(profileDir)
|
|
|
|
const boundary = '----hermes-skill-import-encoded-filename'
|
|
const ctx: any = {
|
|
get: vi.fn((header: string) => header.toLowerCase() === 'content-type' ? `multipart/form-data; boundary=${boundary}` : ''),
|
|
req: Readable.from([multipartBody(boundary, [
|
|
{ name: 'file', filenameStar: 'demo-skill%2FSKILL.md', contentType: 'text/markdown', value: '# Demo Skill\nencoded filename\n' },
|
|
])]),
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
try {
|
|
const { importSkill } = await loadController()
|
|
|
|
await importSkill(ctx)
|
|
|
|
await expect(readFile(join(profileDir, 'skills', 'demo-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# Demo Skill\nencoded filename\n')
|
|
expect(ctx.body).toEqual({ success: true, name: 'demo-skill' })
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('deletes local skills only from the request-scoped profile directory', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-delete-profile-'))
|
|
const defaultProfileDir = join(root, 'default')
|
|
const researchProfileDir = join(root, 'research')
|
|
const defaultSkillDir = join(defaultProfileDir, 'skills', 'tools', 'dupe-skill')
|
|
const researchSkillDir = join(researchProfileDir, 'skills', 'tools', 'dupe-skill')
|
|
await mkdir(defaultSkillDir, { recursive: true })
|
|
await mkdir(researchSkillDir, { recursive: true })
|
|
await writeFile(join(defaultSkillDir, 'SKILL.md'), '# Default Copy\n', 'utf-8')
|
|
await writeFile(join(researchSkillDir, 'SKILL.md'), '# Research Copy\n', 'utf-8')
|
|
mockGetProfileDir.mockImplementation((profile: string) => profile === 'research' ? researchProfileDir : defaultProfileDir)
|
|
|
|
const ctx: any = {
|
|
params: { category: 'tools', skill: 'dupe-skill' },
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
try {
|
|
const { deleteSkill } = await loadController()
|
|
|
|
await deleteSkill(ctx)
|
|
|
|
await expect(readFile(join(defaultSkillDir, 'SKILL.md'), 'utf-8')).resolves.toBe('# Default Copy\n')
|
|
await expect(readFile(join(researchSkillDir, 'SKILL.md'), 'utf-8')).rejects.toThrow()
|
|
expect(ctx.body).toEqual({ success: true })
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('updates local skill content in the request-scoped profile directory', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-update-skill-'))
|
|
const defaultProfileDir = join(root, 'default')
|
|
const researchProfileDir = join(root, 'research')
|
|
const defaultSkillDir = join(defaultProfileDir, 'skills', 'tools', 'dupe-skill')
|
|
const researchSkillDir = join(researchProfileDir, 'skills', 'tools', 'dupe-skill')
|
|
await mkdir(defaultSkillDir, { recursive: true })
|
|
await mkdir(researchSkillDir, { recursive: true })
|
|
await writeFile(join(defaultSkillDir, 'SKILL.md'), '# Default Copy\n', 'utf-8')
|
|
await writeFile(join(researchSkillDir, 'SKILL.md'), '# Research Copy\n', 'utf-8')
|
|
mockGetProfileDir.mockImplementation((profile: string) => profile === 'research' ? researchProfileDir : defaultProfileDir)
|
|
|
|
const ctx: any = {
|
|
query: {},
|
|
params: { category: 'tools', skill: 'dupe-skill' },
|
|
request: { body: { content: '# Updated Research Copy\n' } },
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
try {
|
|
const { updateSkill } = await loadController()
|
|
|
|
await updateSkill(ctx)
|
|
|
|
await expect(readFile(join(defaultSkillDir, 'SKILL.md'), 'utf-8')).resolves.toBe('# Default Copy\n')
|
|
await expect(readFile(join(researchSkillDir, 'SKILL.md'), 'utf-8')).resolves.toBe('# Updated Research Copy\n')
|
|
expect(ctx.body).toEqual({ success: true })
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('updates Codex user skills but not Codex system skills', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-update-codex-skill-'))
|
|
const previousHome = process.env.HOME
|
|
const userSkillDir = join(root, '.agents', 'skills', 'user-skill')
|
|
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'system-skill')
|
|
|
|
await mkdir(userSkillDir, { recursive: true })
|
|
await mkdir(systemSkillDir, { recursive: true })
|
|
await writeFile(join(userSkillDir, 'SKILL.md'), '# User Skill\n', 'utf-8')
|
|
await writeFile(join(systemSkillDir, 'SKILL.md'), '# System Skill\n', 'utf-8')
|
|
process.env.HOME = root
|
|
|
|
try {
|
|
const { updateSkill } = await loadController()
|
|
const userCtx: any = {
|
|
query: { target: 'codex' },
|
|
params: { category: 'misc', skill: 'user-skill' },
|
|
request: { body: { content: '# Updated User Skill\n' } },
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
await updateSkill(userCtx)
|
|
|
|
await expect(readFile(join(userSkillDir, 'SKILL.md'), 'utf-8')).resolves.toBe('# Updated User Skill\n')
|
|
expect(userCtx.body).toEqual({ success: true })
|
|
|
|
const systemCtx: any = {
|
|
query: { target: 'codex' },
|
|
params: { category: 'misc', skill: 'system-skill' },
|
|
request: { body: { content: '# Updated System Skill\n' } },
|
|
state: { profile: { name: 'research' } },
|
|
body: null,
|
|
}
|
|
|
|
await updateSkill(systemCtx)
|
|
|
|
await expect(readFile(join(systemSkillDir, 'SKILL.md'), 'utf-8')).resolves.toBe('# System Skill\n')
|
|
expect(systemCtx.status).toBe(404)
|
|
expect(systemCtx.body).toEqual({ error: 'Skill not found' })
|
|
} finally {
|
|
if (previousHome == null) delete process.env.HOME
|
|
else process.env.HOME = previousHome
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|