1import { test, expect, mock } from 'claude-code/testing' 2import type { On } from 'claude-code' 3 4// The test file runs where timers exist; the mod's own realm, which the types describe, has none. 5declare const setTimeout: (run: (value: unknown) => void, ms: number) => unknown 6 7type Sent = { path: string; body: any } 8 9// Stands in for the daemon and for the host calls the plugin makes. `answer` 10// is what POST /decide returns; undefined makes the socket unreachable. 11function host(on: On, answer: unknown): Sent[] { 12 const sent: Sent[] = [] 13 mock.clock(on) 14 // A call on `$` that resolves to a plain value is answered as { value }. 15 on('env.get', (_$, e) => ({ value: e.name === 'HOME' ? '/home/test' : undefined })) 16 on('session.id', () => ({ value: 'session-1' })) 17 on('session.cwd', () => ({ value: '/work' })) 18 on('session.root', () => ({ value: '/work' })) 19 on('ui.log', () => ({ value: undefined })) 20 on('command.register', (_$, e) => ({ value: { command: e.name } })) 21 on('http.fetch', (_$, e) => { 22 const path = e.url.replace('http://jevhooks', '') 23 sent.push({ path, body: e.init?.body === undefined ? undefined : JSON.parse(e.init.body) }) 24 if (answer === undefined) throw new Error('connect ENOENT') 25 return { value: { status: 200, ok: true, headers: {}, text: JSON.stringify(path === '/decide' ? answer : {}) } } 26 }) 27 // What the engine draws when no plugin draws the band: nothing of ours. 28 on('ui.render', ($, e) => { 29 const { Box } = $.ui.resolve(e) 30 return <Box /> 31 }) 32 on('classic.PreToolUse', () => ({})) 33 on('classic.Stop', () => ({})) 34 on('classic.UserPromptSubmit', () => ({})) 35 on('tool.call', () => ({ result: { stdout: '', stderr: '', interrupted: false } }) as any) 36 return sent 37} 38 39// What the engine hands the band above the prompt. 40const BAND = { hasSurvey: false, isWorking: false, maxRows: 4, bodyColumns: 100, scroll: { offset: 0, bodyRows: 24 }, view: {} } 41 42const decision = (verdict: string, line: string) => ({ verdict, line, ms: 120, session_usd: 0.0002 }) 43 44test('a plainly read-only command is never sent to the daemon', async ($, on) => { 45 const sent = host(on, decision('allow', 'unused')) 46 await $.tool.call({ tool: 'Bash', command: 'git status' }) 47 expect(sent.length).toBe(0) 48}) 49 50test('a risky command is sent to the daemon with its session and directories', async ($, on) => { 51 const sent = host(on, decision('allow', 'Jev: 3% likely destructive; allowed')) 52 await $.tool.call({ tool: 'Bash', command: 'rm -rf target' }) 53 expect(sent.length).toBe(1) 54 expect(sent[0]!.path).toBe('/decide') 55 expect(sent[0]!.body.event).toBe('PreToolUse') 56 expect(sent[0]!.body.session).toBe('session-1') 57 expect(sent[0]!.body.root).toBe('/work') 58 expect(sent[0]!.body.input.command).toBe('rm -rf target') 59}) 60 61test('a tool other than Bash is not judged', async ($, on) => { 62 const sent = host(on, decision('ask', 'unused')) 63 await $.tool.call({ tool: 'Read', file_path: '/work/README.md' }) 64 expect(sent.length).toBe(0) 65}) 66 67test('a turn that Jev says stopped early is sent back with the reason', async ($, on) => { 68 host(on, decision('ask', 'Jev: 91% likely stopped early: work asked for is left undone and no obstacle is named')) 69 const result = await $.classic.Stop({ stop_hook_active: false, last_assistant_message: 'I will do that next.' }) 70 expect(result.block).toMatch(/91% likely stopped early/) 71}) 72 73test('a turn ends as usual when Jev passes, and when the daemon is unreachable', async ($, on) => { 74 host(on, undefined) 75 const result = await $.classic.Stop({ stop_hook_active: false, last_assistant_message: 'Done.' }) 76 expect(result.block).toBe(undefined) 77}) 78 79test('a submitted prompt is reported to the daemon without being answered', async ($, on) => { 80 const sent = host(on, decision('pass', 'unused')) 81 await $.classic.UserPromptSubmit({ prompt: 'fix the build', source: 'user' }) 82 // The report is sent without being waited on, so the hook has returned before it leaves. 83 for (let waited = 0; sent.length === 0 && waited < 50; waited++) await new Promise((done) => setTimeout(done, 10)) 84 expect(sent.map((s) => s.path)).toEqual(['/observe']) 85 expect(sent[0]!.body.input.prompt).toBe('fix the build') 86}) 87 88for (const surface of ['terminal', 'desktop'] as const) { 89 test(`the band shows the last decision (${surface})`, async ($, on) => { 90 host(on, decision('ask', 'Jev: discards or rewrites version-control state (97%); hard to undo (2.5 of 3)')) 91 await $.tool.call({ tool: 'Bash', command: 'git push --force origin main' }) 92 const band = await $.ui.mount({ plugin: 'jevhooks', surface, component: 'AbovePrompt', props: BAND }) 93 expect((await band.findAll({ type: 'Text', text: /version-control state \(97%\)/ })).length).toBe(1) 94 }) 95}