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 = {} 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) })