Test plugins in isolation with the built-in TestKit and mocks — no running xbot instance required. The helpers live in plugin/testkit.go and plugin/mock.go; the real examples ship tests: plugins/xbot-genui/main_test.go, plugins/xbot-git-fancy/main_test.go.
TestKit provides a complete in-memory PluginContext (map storage, test logger, registries):
func TestMyPlugin(t *testing.T) {
tk := plugin.NewTestKit(t)
defer tk.Clear()
p := NewMyPlugin()
if err := tk.Activate(p); err != nil {
t.Fatalf("activate: %v", err)
}
// Assert declared capabilities were actually registered
tk.AssertToolRegistered("hello")
tk.AssertHookRegistered(plugin.HookPostToolUse)
tk.AssertEnricherRegistered("hello_status")
// Call a tool and inspect the result
result, err := tk.CallTool("hello", `{"name":"Alice"}`)
if err != nil {
t.Fatalf("call: %v", err)
}
if !strings.Contains(result.Content, "Hello, Alice") {
t.Errorf("unexpected result: %s", result.Content)
}
}
Other members: tk.Context (the PluginContext), tk.Deactivate(p), tk.Debug/tk.Debugf (write into the captured log). The test logger records everything and formats structured fields.
plugin/mock.go — chainable builders (each With* mutates and returns the same pointer):
mp := plugin.NewMockPlugin("xbot.mock").
WithManifest(func(m *plugin.PluginManifest) {
m.Name = "Mock"
}).
WithActivate(func(ctx plugin.PluginContext) error { return nil }).
WithDeactivate(func(ctx plugin.PluginContext) error { return nil })
mt := plugin.NewMockTool("mock_tool").
WithDefinition(func(d *plugin.ToolDef) { d.Description = "..." }).
WithExecute(func(ctx context.Context, input string) (*plugin.ToolResult, error) {
return plugin.NewToolResult("mocked"), nil
})
⚠️ Do not share a single mock across parallel tests — clone it per test (the chain API mutates in place).
Manifest validity — ID format, permission strings, version semver:
if !plugin.IsValidPermission("tools.register") { t.Fatal(...) }The git-fancy pattern (
plugins/xbot-git-fancy/main_test.go TestManifestPermissions) readsplugin.jsonfrom disk and asserts every declared permission is known — catches the “backend whitelist drifted” failure mode.Activation idempotency — call
Activatetwice; the second must succeed or cleanly no-op.Tool contract — every declared tool name exists, parses its input, returns structured output. Test both happy paths and malformed input (missing params → graceful default or error result).
Hook decisions —
PreToolUsedenial blocks;PostToolUseobserves with correct payload fields.Storage round-trip —
Set→Get→ restart (new storage from same dir) →Get.Deactivation — resources released; calling
Deactivatetwice is safe.
For stdio backends, test the handlers directly (the git-fancy pattern):
func TestGitStatus(t *testing.T) {
// call handleWebPluginRPC / gitStatus with a temp git repo
dir := t.TempDir()
runGit(t, dir, "init")
result := gitStatus(dir)
if !result.is_repo { t.Fatal("expected repo") }
}
Plus one protocol-level test that feeds JSON lines into the process and checks responses (spawn the binary with protocol.Run against an in-memory reader/writer — protocol.run accepts injected io.Reader/io.Writer).
plugin/integration.gowires plugins into a full agent for end-to-end tests —WireAllconnects tools/hooks/enrichers to the registry; individualWire*functions allow partial wiring.- For channel plugins, test
handleActivate’s declaration JSON,handleExecuteToolfor each tool, andhandle_xbot_eventrouting with syntheticchannel_configmessages (mirrorecho-channel/main.py’s handlers). - Rate limiter and quota manager (
plugin/ratelimit.go) have their own test hooks (SetRetryInterval, etc.) — use them to keep tests fast.
web/src/plugin-api/types.test.ts uses @ts-expect-error compile-time assertions to pin the type contracts. For view components:
- mock
usePluginRuntimewith a stable reference (a fresh object per render hangs the worker —vi.hoistedpattern). - inject
window.Reactbefore dynamic import of the plugin module (static imports are hoisted above injection — see the git-fancy index test). - assert hook-count stability across loading → loaded transitions (the React #310 regression: hooks after conditional early returns).