[DOCS] claude plugin test: document how test hooks answer for the engine ({ value } for ops, { deny } vs throw for refusals)
Documentation Type
Missing documentation (feature not documented)
Documentation Location
claude-code/testing (the typings written beside a mod as .claude-plugin/types/claude-code/index.d.ts) and the plugin-authoring skill's reference.md ("what a test holds").
Section/Topic
claude plugin test: writing the test's own on hooks that stand for the engine beneath the plugin
Current Documentation
The docs say the test's hooks "sit beneath the plugin" and "stand for the engine, so what one answers reaches the plugin as given", and describe mock.clock / mock.store / mock.env / mock.session. They don't say what shape those hooks must answer with, or how to simulate an engine refusal.
What's Wrong or Missing?
We had to discover these by trial and error; two of them make failures look like something else:
- Op hooks must return
{ value }. Returning the bare value fails withreturned something that is not a result object.
``ts``
on('session.root', () => 'C:/proj') // fails
on('session.root', () => ({ value: 'C:/proj' })) // works
- Engine refusals must be answered with
{ deny }, notthrow. If the test hook throws to simulate a refusal, the kit treats it as a skipped hook and the plugin's call rejects withno implementation for <op>instead of the reason:
``ts"/${e.name}" refused: ...
// plugin receives "no implementation for command.register"
on('command.register', ($, e) => { throw new Error() })"/${e.name}" refused: it is the user's /${e.name}
// plugin's $.command.register rejects with this reason, as in a real session
on('command.register', ($, e) => ({ deny: }))`
throw
Using hid a real-session bug from our tests (a refused command name cascading through the plugin's init) until we switched to deny`.
- Some answers are required and missing ones fail silently. Without test hooks for
session.start,session.rootandcommand.register, the plugin's initialization hook is skipped and the test fails later, far from the cause. - The test
$has nostorenoun. State has to be seeded withmock.store(on, entries)and checked through behavior. $.command.runin a test takes the full input (origin,presentation), not theCommandRunArgsshape a plugin passes.$.ui.askis mocked throughtool.call { tool: 'AskUserQuestion' }, answering with that tool's result shape.
Suggested Improvement
Add an "answering for the engine" section to the claude-code/testing docs with the points above, especially { value } for op hooks and { deny } vs throw for refusals, plus a minimal example of the hooks a typical mod needs (session.start, session.root, command.register).
Optionally: report a throwing test hook as a test error instead of a skipped hook, and add helpers for the common answers (e.g. mock.session(on, { root }), mock.commands(on, { refuse })).
Impact
Medium - Makes feature difficult to understand
Additional Context
- Claude Code 2.1.296 (type declarations written by 2.1.295), Windows 11.
- Related test-kit issues: #100929 (
no implementation for ui.focuseven with a test hook), #99995 (test$lacks nouns a plugin adds).