feat: 階段 4 工具二(助理技能產生、驗證實跑、技能 README 與版本號、PR)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
import { bumpVersion, parseEntries, renderGuide, upsertEntry, validateSkill } from './skillgen'
|
||||
|
||||
const SKILL = '---\nname: anima-greet\ndescription: 打招呼\n---\n\n用角色口吻向使用者打招呼,並簡短說明今天的待辦。\n'
|
||||
const GENERATED = JSON.stringify({ skill: SKILL, example: '早安' })
|
||||
|
||||
type Calls = { argv: string[] }[]
|
||||
|
||||
// 啟動角色並替身所有外部:模型、檔案系統、git/tea。
|
||||
async function setup($: any, on: any, opts: { reply?: (prompt: string) => string | undefined; existing?: boolean } = {}) {
|
||||
const writes: Record<string, string> = {}
|
||||
const procs: Calls = []
|
||||
mock.store(on)
|
||||
on('command.register', () => ({ value: { name: 'x' } }) as never)
|
||||
on('session.start', () => ({ cwd: '/repo' }) as never)
|
||||
on('session.cwd', () => ({ value: '/repo' }))
|
||||
on('command.run', () => ({ text: '' }))
|
||||
on('prompt.compose', () => ({ sections: [] }))
|
||||
on('model.complete', (_: any, e: any) => {
|
||||
const t = opts.reply ? opts.reply(String(e.prompt)) : String(e.prompt).startsWith('請撰寫') ? GENERATED : '嗨!'
|
||||
return { value: t === undefined ? { isAnswered: false, reason: 'empty-reply' } : { isAnswered: true, text: t, usage: {} } }
|
||||
})
|
||||
on('process.run', (_: any, e: any) => {
|
||||
procs.push({ argv: [...e.argv] })
|
||||
const s = e.argv.join(' ')
|
||||
if (s.startsWith('git rev-parse')) return { value: { exitCode: 0, stdout: '/repo\n', stderr: '' } }
|
||||
if (s.startsWith('git symbolic-ref')) return { value: { exitCode: 0, stdout: 'origin/main\n', stderr: '' } }
|
||||
if (s.startsWith('tea logins')) return { value: { exitCode: 0, stdout: 'admin\n', stderr: '' } }
|
||||
if (s.startsWith('tea pulls')) return { value: { exitCode: 0, stdout: 'https://g/pulls/1\n', stderr: '' } }
|
||||
return { value: { exitCode: 0, stdout: '', stderr: '' } }
|
||||
})
|
||||
on('fs.exists', (_: any, e: any) => ({ value: Boolean(opts.existing && String(e.path).endsWith('anima-greet/SKILL.md')) }))
|
||||
on('fs.read', (_: any, e: any) => ({
|
||||
value: String(e.path).endsWith('plugin.json') ? '{\n "version": "0.3.0"\n}' : String(e.path).endsWith('README.md') ? '# jsc-anima\n' : '',
|
||||
}))
|
||||
on('fs.write', (_: any, e: any) => {
|
||||
writes[e.path] = e.text
|
||||
return { value: undefined }
|
||||
})
|
||||
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
|
||||
return { writes, procs }
|
||||
}
|
||||
|
||||
const run = ($: any, args: string) => $.command.run({ command: 'anima-skill', args } as never)
|
||||
|
||||
test('未啟動角色時工具二被拒絕並提示先啟動', async ($, on) => {
|
||||
const { procs } = await setup($, on)
|
||||
const r = await run($, 'greet 打招呼')
|
||||
expect(r.text.includes('請先啟動角色')).toBe(true)
|
||||
expect(procs.length).toBe(0)
|
||||
})
|
||||
|
||||
test('新增技能:寫檔、升 minor、更新 README、建立 PR', async ($, on) => {
|
||||
const { writes, procs } = await setup($, on)
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
const r = await run($, 'greet 打招呼')
|
||||
const key = (suffix: string) => Object.keys(writes).find(k => k.endsWith(suffix))!
|
||||
expect(writes[key('skills/anima-greet/SKILL.md')]!.includes('name: anima-greet')).toBe(true)
|
||||
expect(writes[key('.claude-plugin/plugin.json')]!.includes('"0.4.0"')).toBe(true)
|
||||
expect(writes[key('/README.md')]!.includes('目前版本:0.4.0') || Object.values(writes).some(v => v.includes('目前版本:0.4.0'))).toBe(true)
|
||||
expect(writes[key('skills/jsc-anima/README.md')]!.includes('/jsc-anima:anima-greet')).toBe(true)
|
||||
expect(procs.some(p => p.argv.join(' ').startsWith('git commit') && p.argv.join(' ').includes('feat: 新增技能'))).toBe(true)
|
||||
const pr = procs.find(p => p.argv[0] === 'tea' && p.argv[1] === 'pulls')!
|
||||
expect(pr.argv.includes('main')).toBe(true)
|
||||
expect(pr.argv.includes('feat/anima-greet')).toBe(true)
|
||||
expect(r.text.includes('https://g/pulls/1')).toBe(true)
|
||||
})
|
||||
|
||||
test('技能已存在則覆蓋並升 patch', async ($, on) => {
|
||||
const { writes } = await setup($, on, { existing: true })
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
await run($, 'greet 打招呼')
|
||||
expect(Object.entries(writes).some(([k, v]) => k.endsWith('plugin.json') && v.includes('"0.3.1"'))).toBe(true)
|
||||
})
|
||||
|
||||
test('驗證失敗或實跑失敗就不建立 PR', async ($, on) => {
|
||||
const bad = await setup($, on, { reply: () => JSON.stringify({ skill: 'no frontmatter', example: 'x' }) })
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
const r1 = await run($, 'greet 打招呼')
|
||||
expect(r1.text.includes('驗證失敗')).toBe(true)
|
||||
expect(bad.procs.some(p => p.argv[0] === 'tea')).toBe(false)
|
||||
expect(Object.keys(bad.writes).length).toBe(0)
|
||||
})
|
||||
|
||||
test('實跑無回應就不建立 PR', async ($, on) => {
|
||||
const s = await setup($, on, { reply: p => (p.startsWith('請撰寫') ? GENERATED : undefined) })
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
const r = await run($, 'greet 打招呼')
|
||||
expect(r.text.includes('實跑失敗')).toBe(true)
|
||||
expect(s.procs.some(p => p.argv[0] === 'tea')).toBe(false)
|
||||
})
|
||||
|
||||
test('技能驗證與版本規則', () => {
|
||||
expect(validateSkill(SKILL, 'greet').length).toBe(0)
|
||||
expect(validateSkill(SKILL, 'other').length).toBe(1)
|
||||
expect(bumpVersion('0.3.4', 'minor')).toBe('0.4.0')
|
||||
expect(bumpVersion('0.3.4', 'patch')).toBe('0.3.5')
|
||||
const list = upsertEntry(parseEntries(renderGuide([{ skill: 'a', description: 'A' }])), { skill: 'b', description: 'B' })
|
||||
expect(list.length).toBe(2)
|
||||
expect(renderGuide(list).includes('# /jsc-anima:jsc-anima')).toBe(true)
|
||||
})
|
||||
Reference in New Issue
Block a user