codev / src /server /__tests__ /skills.test.ts
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame Contribute Delete
9.73 kB
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { getCwdState, setCwdState } from '../../bootstrap/state.js'
import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js'
import { clearPluginCache } from '../../utils/plugins/pluginLoader.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import { handlePluginsApi } from '../api/plugins.js'
import { handleSkillsApi } from '../api/skills.js'
let tmpHome: string
let originalHome: string | undefined
let originalUserProfile: string | undefined
let originalClaudeConfigDir: string | undefined
let originalCwdState: string
function makeRequest(urlStr: string): { req: Request; url: URL; segments: string[] } {
const url = new URL(urlStr, 'http://localhost:3456')
const req = new Request(url.toString(), { method: 'GET' })
return {
req,
url,
segments: url.pathname.split('/').filter(Boolean),
}
}
function makePluginReloadRequest(): { req: Request; url: URL; segments: string[] } {
const url = new URL('/api/plugins/reload', 'http://localhost:3456')
const req = new Request(url.toString(), { method: 'POST' })
return {
req,
url,
segments: url.pathname.split('/').filter(Boolean),
}
}
async function writeSkill(root: string, skillName: string, content: string): Promise<void> {
const skillDir = path.join(root, skillName)
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8')
}
describe('Skills API', () => {
beforeEach(async () => {
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-skills-test-'))
originalHome = process.env.HOME
originalUserProfile = process.env.USERPROFILE
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
originalCwdState = getCwdState()
process.env.HOME = tmpHome
process.env.USERPROFILE = tmpHome
process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude')
setCwdState(tmpHome)
clearInstalledPluginsCache()
clearPluginCache('skills-api-test-setup')
resetSettingsCache()
})
afterEach(async () => {
clearInstalledPluginsCache()
clearPluginCache('skills-api-test-teardown')
resetSettingsCache()
if (originalHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = originalHome
}
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE
} else {
process.env.USERPROFILE = originalUserProfile
}
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
setCwdState(originalCwdState)
await fs.rm(tmpHome, { recursive: true, force: true })
})
it('lists user and project skills for the requested cwd', async () => {
const userSkillsRoot = path.join(tmpHome, '.claude', 'skills')
const projectRoot = path.join(tmpHome, 'workspace')
const cwd = path.join(projectRoot, 'packages', 'app')
await writeSkill(
userSkillsRoot,
'user-skill',
['---', 'description: User scope', '---', '', '# User skill'].join('\n'),
)
await writeSkill(
path.join(projectRoot, '.claude', 'skills'),
'project-skill',
['---', 'description: Project scope', '---', '', '# Project skill'].join('\n'),
)
const { req, url, segments } = makeRequest(`/api/skills?cwd=${encodeURIComponent(cwd)}`)
const res = await handleSkillsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as { skills: Array<{ name: string; source: string }> }
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'user-skill', source: 'user' }))
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'project-skill', source: 'project' }))
})
it('lists user skills installed through a directory symlink or junction', async () => {
const linkedSkillsRoot = path.join(tmpHome, '.agents', 'skills')
const userSkillsRoot = path.join(tmpHome, '.claude', 'skills')
const projectRoot = path.join(tmpHome, 'workspace')
const cwd = path.join(projectRoot, 'packages', 'app')
await writeSkill(
linkedSkillsRoot,
'linked-skill',
['---', 'description: Linked skill', '---', '', '# Linked skill'].join('\n'),
)
await fs.mkdir(userSkillsRoot, { recursive: true })
await fs.symlink(
path.join(linkedSkillsRoot, 'linked-skill'),
path.join(userSkillsRoot, 'linked-skill'),
process.platform === 'win32' ? 'junction' : 'dir',
)
const { req, url, segments } = makeRequest(`/api/skills?cwd=${encodeURIComponent(cwd)}`)
const res = await handleSkillsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as { skills: Array<{ name: string; source: string }> }
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'linked-skill', source: 'user' }))
})
it('resolves project skill details from the nearest project skills directory', async () => {
const projectRoot = path.join(tmpHome, 'workspace')
const nestedRoot = path.join(projectRoot, 'packages', 'app')
const nestedSkillsRoot = path.join(nestedRoot, '.claude', 'skills')
const parentSkillsRoot = path.join(projectRoot, '.claude', 'skills')
await writeSkill(
parentSkillsRoot,
'shared-skill',
['---', 'description: Parent version', '---', '', 'parent body'].join('\n'),
)
await writeSkill(
nestedSkillsRoot,
'shared-skill',
['---', 'description: Child version', '---', '', 'child body'].join('\n'),
)
const { req, url, segments } = makeRequest(
`/api/skills/detail?source=project&name=shared-skill&cwd=${encodeURIComponent(nestedRoot)}`,
)
const res = await handleSkillsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as {
detail: { meta: { description: string }; skillRoot: string; files: Array<{ path: string; body?: string }> }
}
expect(body.detail.meta.description).toBe('Child version')
expect(body.detail.skillRoot).toBe(path.join(nestedSkillsRoot, 'shared-skill'))
expect(body.detail.files).toContainEqual(
expect.objectContaining({ path: 'SKILL.md', body: 'child body' }),
)
})
it('lists plugin skills after reload rereads an external enable toggle', async () => {
const marketplaceRoot = path.join(tmpHome, 'marketplace-root')
const pluginRoot = path.join(marketplaceRoot, 'plugins', 'draw')
const pluginsDir = path.join(tmpHome, '.claude', 'plugins')
const marketplaceFile = path.join(
marketplaceRoot,
'.claude-plugin',
'marketplace.json',
)
await fs.mkdir(path.join(pluginRoot, '.claude-plugin'), { recursive: true })
await fs.mkdir(path.join(pluginRoot, 'skills', 'render'), { recursive: true })
await fs.mkdir(path.dirname(marketplaceFile), { recursive: true })
await fs.mkdir(pluginsDir, { recursive: true })
await fs.writeFile(
path.join(pluginRoot, '.claude-plugin', 'plugin.json'),
JSON.stringify({
name: 'draw',
version: '1.0.0',
description: 'Drawing plugin',
}),
'utf-8',
)
await fs.writeFile(
path.join(pluginRoot, 'skills', 'render', 'SKILL.md'),
[
'---',
'description: Render with the drawing plugin.',
'---',
'',
'# Render',
].join('\n'),
'utf-8',
)
await fs.writeFile(
marketplaceFile,
JSON.stringify({
name: 'test-market',
owner: { name: 'Test' },
plugins: [
{
name: 'draw',
source: './plugins/draw',
version: '1.0.0',
},
],
}),
'utf-8',
)
await fs.writeFile(
path.join(pluginsDir, 'known_marketplaces.json'),
JSON.stringify({
'test-market': {
source: { source: 'directory', path: marketplaceRoot },
installLocation: marketplaceRoot,
lastUpdated: new Date(0).toISOString(),
},
}),
'utf-8',
)
const settingsPath = path.join(tmpHome, '.claude', 'settings.json')
await fs.writeFile(
settingsPath,
JSON.stringify({
enabledPlugins: {
'draw@test-market': false,
},
}),
'utf-8',
)
const initial = makeRequest('/api/skills')
const initialRes = await handleSkillsApi(initial.req, initial.url, initial.segments)
const initialBody = await initialRes.json() as {
skills: Array<{ name: string; source: string }>
}
expect(initialBody.skills).not.toContainEqual(
expect.objectContaining({ name: 'draw:render', source: 'plugin' }),
)
await fs.writeFile(
settingsPath,
JSON.stringify({
enabledPlugins: {
'draw@test-market': true,
},
}),
'utf-8',
)
const reload = makePluginReloadRequest()
const reloadRes = await handlePluginsApi(reload.req, reload.url, reload.segments)
expect(reloadRes.status).toBe(200)
const after = makeRequest('/api/skills')
const afterRes = await handleSkillsApi(after.req, after.url, after.segments)
const afterBody = await afterRes.json() as {
skills: Array<{ name: string; source: string; description: string }>
}
expect(afterBody.skills).toContainEqual(
expect.objectContaining({
name: 'draw:render',
source: 'plugin',
description: 'Render with the drawing plugin.',
}),
)
})
})