150 lines
6.5 KiB
TypeScript
150 lines
6.5 KiB
TypeScript
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)
|
||
})
|