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; pushOk?: boolean } = {}) => { const files = new Map(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).toEqual([...registered.filter(n => n === 'anima-off' || n.startsWith('sao-'))]) 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) })