jevhooks.git / plugin / tests / README.md
1# Chapter 5: tests, a daemon made of cardboard
2
3How do you test code that only runs inside Claude Code, talks to a daemon
4over a Unix socket, and asks a model on the internet for its opinion? You
5replace everything it touches with something you control, and watch what it
6tries to do.
7
8Claude Code ships a test runner for mods (`claude plugin test`, with
9`claude-code/testing`). In a test, the mod's `$` calls are hooks too, so a
10test can register its own handler for `http.fetch` and be the daemon, or
11for `session.id` and be the session. The test file's `host(on, answer)` does
12exactly that:
13
14- `http.fetch` records the path and body of every request, then answers
15  `/decide` with `answer`, and anything else with `{}`. Pass `undefined`
16  and it throws `connect ENOENT`, which is what a missing socket looks like.
17- `env.get`, `session.id`, `session.cwd`, `session.root` give a fixed home,
18  session and project (`/home/test`, `session-1`, `/work`).
19- `classic.PreToolUse`, `classic.Stop`, `classic.UserPromptSubmit` and
20  `tool.call` stand in for whatever would sit beneath the plugin, so `next(e)`
21  has something to call.
22
23Then a test drives the mod the way Claude Code would (`$.tool.call`,
24`$.classic.Stop`, `$.ui.mount`) and looks at what was sent and what came
25back.
26
27| Test | What it pins |
28| --- | --- |
29| a plainly read-only command is never sent to the daemon | `git status` makes no request at all. |
30| a risky command is sent to the daemon with its session and directories | `rm -rf target` makes one `/decide` with the event, the session, the root and the command. |
31| a tool other than Bash is not judged | A `Read` makes no request. |
32| a turn that Jev says stopped early is sent back with the reason | An `ask` on `Stop` becomes a `block` carrying Jev's line. |
33| a turn ends as usual when Jev passes, and when the daemon is unreachable | No daemon, no block. |
34| a submitted prompt is reported to the daemon without being answered | `UserPromptSubmit` sends one `/observe`, after the hook has already returned. |
35| the band shows the last decision (terminal), (desktop) | After a judged command, the band above the prompt has the line, on both surfaces. |
36
37> **Aside: the test that has to wait.** Most of these finish when the hook
38> returns. The prompt test cannot: the mod sends `/observe` without
39> waiting, on purpose, so the hook has returned before the request leaves.
40> The test polls for up to half a second (50 times 10 ms) using a
41> `setTimeout` that exists in the test file's world, and not in the mod's.
42> That is why the file declares `setTimeout` itself.
43
44> **Try it.**
45>
46>     claude plugin test plugin
47>
48> from the repository root: eight tests, about five seconds, no daemon, no
49> key and no Rust, because the generated `jevhooks.js` is committed. Break
50> something on purpose (make `classic.Stop` return `beneath` always) and
51> watch the stopped-early test fail.
52
53## For the people who maintain it
54
55The fake `answer` lines are written to look like the daemon's, but they are
56only strings: the tests check the mod's plumbing, not Jev's wording. The
57verdict rules are tested in Rust (`crates/jevhooks-daemon/src/judge.rs`,
58chapter 12); the routing in `crates/jevhooks-mod` (chapter 10).
59
60### In this folder
61
62| Path | What |
63| --- | --- |
64| [jevhooks.test.tsx](jevhooks.test.tsx) | The stand-in host and daemon, and the eight tests. |
65
66← Previous: [Chapter 4, types/](../types/) · Up: [plugin](../) · Next: [Chapter 6, crates/](../../crates/) →