skillhub/cli/test/integration/doctor-command.test.ts
dongmucat a14d89d8c9 refactor(cli): improve doctor command semantics and transparency
变更摘要:

- doctor 命令重构:从重建改为扫描并合并语义,保留扫描范围外的条目

- 修复字段命名:itemsRestored → itemsScanned/itemsPreserved,语义更清晰

- 改进用户提示:输出区分扫描到的和保留的条目,帮助文档补充保留行为说明

- 补充代码注释:说明同 slug 不同 installDir 允许并存的设计意图

- 统一错误码:download/handleJsonResponse 的非 2xx 响应统一使用 EXIT.generic

- 新增测试覆盖:合并场景、刷新场景、冲突不删除无关条目等边界情况

关键文件:

- cli/src/services/doctor-service.ts

- cli/src/commands/doctor.ts

- cli/src/commands/help.ts

- cli/test/unit/services/doctor-service.test.ts

- cli/test/integration/doctor-command.test.ts
2026-05-09 11:30:36 +08:00

230 lines
7 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { mkdir, writeFile, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createTempHome } from '../helpers/temp-env'
import { runCli } from '../helpers/run-cli'
/**
* Seed a single skill metadata file under the given scan root.
* Doctor scans its cwd for `.<agent>/skills/<slug>/.skillhub/metadata.json`,
* so we seed fixtures inside `scanRoot` (a temp dir) and pass that same dir
* as the CLI's cwd — no writes into the repo working tree.
*/
async function seedSkill(scanRoot: string, options: {
agentDir: string
slug: string
metadata: {
registry: string
namespace: string
slug: string
version: string
agent: string
installedAt: string
}
}): Promise<void> {
const metaDir = join(scanRoot, options.agentDir, 'skills', options.slug, '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify(options.metadata))
}
describe('doctor command', () => {
test('doctor --json empty home exits 0 and returns parseable JSON', async () => {
const { home, cwd } = await createTempHome()
const result = await runCli(['doctor', '--json'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout)
expect(json.ok).toBe(true)
expect(typeof json.inventoryPath).toBe('string')
expect(json.inventoryPath).toContain('.skillhub')
expect(json.inventoryPath).toContain('inventory.json')
expect(json.backupPath).toBeNull()
expect(json.itemsScanned).toBe(0)
expect(json.targetsScanned).toBe(0)
expect(json.itemsPreserved).toBe(0)
expect(json.targetsPreserved).toBe(0)
expect(Array.isArray(json.skipped)).toBe(true)
expect(Array.isArray(json.conflicts)).toBe(true)
})
test('doctor human output empty home exits 0 and shows Inventory line', async () => {
const { home, cwd } = await createTempHome()
const result = await runCli(['doctor'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Inventory:')
expect(result.stdout).toContain('.skillhub')
expect(result.stdout).toContain('inventory.json')
expect(result.stdout).toContain('Scanned: 0 items, 0 targets')
expect(result.stdout).not.toContain('Backup:')
})
test('doctor rebuilds inventory.json from seeded metadata files', async () => {
const { home, cwd } = await createTempHome()
await seedSkill(cwd, {
agentDir: '.codex',
slug: 'pdf-parser',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '1.2.0',
agent: 'codex',
installedAt: '2026-04-20T12:00:00Z'
}
})
const result = await runCli(['doctor'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const inventoryPath = join(home, '.skillhub', 'inventory.json')
const raw = await readFile(inventoryPath, 'utf-8')
const inventory = JSON.parse(raw) as { items: Array<{
namespace: string
slug: string
version: string
registry: string
}> }
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '1.2.0'
})
})
test('doctor --json reflects rebuilt inventory items in output', async () => {
const { home, cwd } = await createTempHome()
await seedSkill(cwd, {
agentDir: '.claude',
slug: 'image-resizer',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'image-resizer',
version: '2.0.0',
agent: 'claude-code',
installedAt: '2026-04-21T09:00:00Z'
}
})
const result = await runCli(['doctor', '--json'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout)
expect(json.ok).toBe(true)
expect(json.itemsScanned).toBe(1)
expect(json.targetsScanned).toBe(1)
expect(json.backupPath).toBeNull()
expect(Array.isArray(json.skipped)).toBe(true)
expect(json.conflicts).toHaveLength(0)
expect(typeof json.inventoryPath).toBe('string')
expect(json.inventoryPath).toContain('inventory.json')
})
test('doctor backs up existing inventory.json and reports backupPath', async () => {
const { home, cwd } = await createTempHome()
const skillhubDir = join(home, '.skillhub')
await mkdir(skillhubDir, { recursive: true })
const inventoryPath = join(skillhubDir, 'inventory.json')
const originalContent = JSON.stringify({ items: [{ registry: 'old', namespace: 'x', slug: 'y', version: '0.0.1', targets: [] }] })
await writeFile(inventoryPath, originalContent)
const result = await runCli(['doctor'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const backupPath = `${inventoryPath}.bak`
const backupContent = await readFile(backupPath, 'utf-8')
expect(backupContent).toBe(originalContent)
expect(result.stdout).toContain('Backup:')
expect(result.stdout).toContain('inventory.json.bak')
})
test('doctor merges with existing inventory and preserves out-of-cwd entries', async () => {
const { home, cwd } = await createTempHome()
const skillhubDir = join(home, '.skillhub')
await mkdir(skillhubDir, { recursive: true })
const inventoryPath = join(skillhubDir, 'inventory.json')
await writeFile(inventoryPath, JSON.stringify({
items: [
{
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'external-skill',
version: '1.0.0',
targets: [
{
agent: 'claude-code',
rootDir: '/external/project/.claude',
installDir: '/external/project/.claude/skills/external-skill',
installedAt: '2026-04-01T00:00:00Z'
}
]
}
]
}))
await seedSkill(cwd, {
agentDir: '.claude',
slug: 'local-skill',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'local-skill',
version: '2.0.0',
agent: 'claude-code',
installedAt: '2026-04-21T09:00:00Z'
}
})
const result = await runCli(['doctor', '--json'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout)
expect(json.itemsScanned).toBe(1)
expect(json.itemsPreserved).toBe(1)
expect(json.targetsPreserved).toBe(1)
const raw = await readFile(inventoryPath, 'utf-8')
const inventory = JSON.parse(raw) as {
items: Array<{ slug: string }>
}
expect(inventory.items.map(item => item.slug)).toEqual(
expect.arrayContaining(['external-skill', 'local-skill'])
)
})
})