jevhooks.git / plugin / tests / jevhooks.test.tsx
jevhooks.test.tsx95 lines · 4.6 KB · raw
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}