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.fetch records the path and body of every request, then answers /decide with answer, and anything else with {}. Pass undefined and it throws connect ENOENT, which is what a missing socket looks like.
  • env.get, session.id, session.cwd, session.root give a fixed home, session and project (/home/test, session-1, /work).
  • classic.PreToolUse, classic.Stop, classic.UserPromptSubmit and tool.call stand in for whatever would sit beneath the plugin, so next(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.

TestWhat it pins
a plainly read-only command is never sent to the daemongit status makes no request at all.
a risky command is sent to the daemon with its session and directoriesrm -rf target makes one /decide with the event, the session, the root and the command.
a tool other than Bash is not judgedA Read makes no request.
a turn that Jev says stopped early is sent back with the reasonAn ask on Stop becomes a block carrying Jev's line.
a turn ends as usual when Jev passes, and when the daemon is unreachableNo daemon, no block.
a submitted prompt is reported to the daemon without being answeredUserPromptSubmit 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 /observe without 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 a setTimeout that exists in the test file's world, and not in the mod's. That is why the file declares setTimeout itself.

Try it.

claude plugin test plugin

from the repository root: eight tests, about five seconds, no daemon, no key and no Rust, because the generated jevhooks.js is committed. Break something on purpose (make classic.Stop return beneath always) 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

PathWhat
jevhooks.test.tsxThe stand-in host and daemon, and the eight tests.

← Previous: Chapter 4, types/ · Up: plugin · Next: Chapter 6, crates/ →