Chapter 5: tests, a daemon made of cardboard
How do you test code that only runs inside Claude Code, talks to a daemon over a Unix socket, and asks a model on the internet for its opinion? You replace everything it touches with something you control, and watch what it tries to do.
Claude Code ships a test runner for mods (claude plugin test, with
claude-code/testing). In a test, the mod's $ calls are hooks too, so a
test can register its own handler for http.fetch and be the daemon, or
for session.id and be the session. The test file's host(on, answer) does
exactly that:
http.fetchrecords the path and body of every request, then answers/decidewithanswer, and anything else with{}. Passundefinedand it throwsconnect ENOENT, which is what a missing socket looks like.env.get,session.id,session.cwd,session.rootgive a fixed home, session and project (/home/test,session-1,/work).classic.PreToolUse,classic.Stop,classic.UserPromptSubmitandtool.callstand in for whatever would sit beneath the plugin, sonext(e)has something to call.
Then a test drives the mod the way Claude Code would ($.tool.call,
$.classic.Stop, $.ui.mount) and looks at what was sent and what came
back.
| Test | What it pins |
|---|---|
| a plainly read-only command is never sent to the daemon | git status makes no request at all. |
| 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. |
| a tool other than Bash is not judged | A Read makes no request. |
| a turn that Jev says stopped early is sent back with the reason | An ask on Stop becomes a block carrying Jev's line. |
| a turn ends as usual when Jev passes, and when the daemon is unreachable | No daemon, no block. |
| a submitted prompt is reported to the daemon without being answered | UserPromptSubmit sends one /observe, after the hook has already returned. |
| the band shows the last decision (terminal), (desktop) | After a judged command, the band above the prompt has the line, on both surfaces. |
Aside: the test that has to wait. Most of these finish when the hook returns. The prompt test cannot: the mod sends
/observewithout waiting, on purpose, so the hook has returned before the request leaves. The test polls for up to half a second (50 times 10 ms) using asetTimeoutthat exists in the test file's world, and not in the mod's. That is why the file declaressetTimeoutitself.
Try it.
claude plugin test pluginfrom the repository root: eight tests, about five seconds, no daemon, no key and no Rust, because the generated
jevhooks.jsis committed. Break something on purpose (makeclassic.Stopreturnbeneathalways) and watch the stopped-early test fail.
For the people who maintain it
The fake answer lines are written to look like the daemon's, but they are
only strings: the tests check the mod's plumbing, not Jev's wording. The
verdict rules are tested in Rust (crates/jevhooks-daemon/src/judge.rs,
chapter 12); the routing in crates/jevhooks-mod (chapter 10).
In this folder
| Path | What |
|---|---|
| jevhooks.test.tsx | The stand-in host and daemon, and the eight tests. |
← Previous: Chapter 4, types/ · Up: plugin · Next: Chapter 6, crates/ →