Files

150 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, mock, test } from 'claude-code/testing'
const OPTS = { options: { wikiCacheDir: '/cache/wiki' } }
// 記憶體內的假 wiki:以 process.run/fs.* 事件邊界替代 git 與檔案系統。
const setup = (on: any, opts: { registered?: string[]; online?: boolean; seed?: Record<string, string>; pushOk?: boolean } = {}) => {
const files = new Map<string, string>(Object.entries(opts.seed ?? {}))
const calls: string[][] = []
const state = { online: opts.online ?? true, pushOk: opts.pushOk ?? true, commits: [] as string[] }
const D = '/cache/wiki'
mock.store(on)
mock.env(on, { HOME: '/home/u', GITEA_TOKEN: 'secret-token' })
const clock = mock.clock(on, { now: 1_000_000 })
on('command.register', (_$: any, e: any) => {
opts.registered?.push(e.name)
return { value: { name: e.name } } as never
})
on('session.start', () => ({ cwd: '/proj' }) as never)
on('command.run', () => ({ text: '' }))
on('fs.exists', (_$: any, e: any) => ({ value: files.has(e.path.replace(`${D}/`, '')) || e.path === `${D}/.git` && files.has('.git') }) as never)
on('fs.read', (_$: any, e: any) => {
const k = e.path.replace(`${D}/`, '')
if (!files.has(k)) return { deny: 'ENOENT' } as never
return { value: files.get(k)! } as never
})
on('fs.write', (_$: any, e: any) => {
files.set(e.path.replace(`${D}/`, ''), e.text)
return { value: undefined } as never
})
on('process.run', (_$: any, e: any) => {
const argv = [...e.argv]
calls.push(argv)
const sub = argv.find(a => ['clone', 'pull', 'add', 'commit', 'push', 'reset', 'get-url'].includes(a))
const out = (exitCode = 0, stdout = '') => ({ value: { exitCode, stdout, stderr: '' } }) as never
if (sub === 'get-url') return out(0, 'https://gitea.example/o/r.git\n')
if (sub === 'clone') {
if (!state.online) return out(128)
files.set('.git', '1')
return out()
}
if (sub === 'pull') return state.online ? out() : out(128)
if (sub === 'commit') {
state.commits.push(argv[argv.indexOf('-m') + 1]!)
return out()
}
if (sub === 'push') return state.pushOk ? out() : out(1)
return out()
})
return { files, calls, state, clock }
}
const start = async ($: any) => {
await $.session.start({ source: 'startup', cwd: '/proj' } as never)
}
const run = ($: any, command: string, args = '') => $.command.run({ command, args } as never)
test('未啟動角色時拒絕寫入與讀取', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '內容' } })
await start($)
// 工具指令尚未註冊,也未啟動:直接當作一般指令放行(由 harness 底層回空字串)。
const res = await run($, 'anima-wiki', 'tools git\n內容')
expect(res.text).toBe('')
expect(w.state.commits.length).toBe(0)
})
test('啟動後才註冊工具,並依 wiki 目錄動態註冊', OPTS, async ($, on) => {
const registered: string[] = []
setup(on, { registered, seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n' } })
await start($)
expect(registered).not.toContain('anima-wiki')
expect(registered).not.toContain('tools-git')
await run($, 'sao-asuna')
expect(registered).toContain('anima-wiki')
expect(registered).toContain('tools-git')
})
test('寫入:一個中文 commit、更新 Home 與 _Sidebar、推送,並註冊新指令', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
const res = await run($, 'anima-wiki', 'tools git https://git-scm.com\n用 rebase 整理歷史。')
expect(res.text).toContain('已新增')
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」'])
expect(w.files.get('Home.md')).toContain('## tools')
expect(w.files.get('Home.md')).toContain('- [git](tools-git)')
expect(w.files.get('_Sidebar.md')).toContain('[git](tools-git)')
expect(w.files.get('tools-git.md')).toContain('出處:https://git-scm.com')
expect(w.calls.some(c => c.includes('push'))).toBe(true)
// 讀回
const read = await run($, 'tools-git')
expect(read.text).toContain('用 rebase 整理歷史。')
})
test('覆蓋既有主題用「更新」訊息;來源預設為使用者提供', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
await run($, 'anima-wiki', 'tools git\n第一版')
await run($, 'anima-wiki', 'tools git\n第二版')
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」', 'feat: 更新知識「tools/git」'])
expect(w.files.get('tools-git.md')).toContain('出處:使用者提供')
expect(w.files.get('Home.md')!.match(/\[git\]/g)!.length).toBe(1)
})
test('10 分鐘內讀取用快取,逾時才重新 pull;離線沿用快取', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '舊內容' } })
await start($)
await run($, 'sao-asuna')
const pulls = () => w.calls.filter(c => c.includes('pull')).length
await run($, 'tools-git')
const base = pulls()
await w.clock.advance(5 * 60 * 1000)
await run($, 'tools-git')
expect(pulls()).toBe(base)
await w.clock.advance(6 * 60 * 1000)
w.state.online = false
const res = await run($, 'tools-git')
expect(pulls()).toBe(base + 1)
expect(res.text).toContain('舊內容')
})
test('推送失敗時還原並回報,不註冊指令', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' }, pushOk: false })
await start($)
await run($, 'sao-asuna')
const res = await run($, 'anima-wiki', 'tools git\n內容')
expect(res.text).toContain('失敗')
expect(w.calls.some(c => c.includes('reset'))).toBe(true)
})
test('token 只放在指令參數,不寫入任何檔案', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
await run($, 'anima-wiki', 'tools git\n內容')
const pushCall = w.calls.find(c => c.includes('push'))!
expect(pushCall.join(' ')).toContain('Authorization: token secret-token')
expect([...w.files.values()].some(v => v.includes('secret-token'))).toBe(false)
})
test('離開角色後工具被拒絕並提示先啟動', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
await run($, 'anima-off')
const res = await run($, 'anima-wiki', 'tools git\n內容')
expect(res.text).toContain('請先啟動角色')
expect(w.state.commits.length).toBe(0)
})