Implements the compile-in plugin system designed in spec 019:
- internal/plugin/ (~1,300 LOC):
* Tiny Plugin interface + 10 optional HasX capability sub-interfaces
* Host struct with Logger / DB / Messenger / Channels / Attachments /
Search / Secrets / Events / Config / DataDir / Tracer / Metrics /
DefaultOwner / BaseURL
* Registry with panic-on-duplicate, stability-level tracking, capability
indexing (MCP tools, actions, panels, channel types, routes, event
subscribers, CLI commands)
* Migrator: per-plugin SHA-256-checksum'd migration chain, namespaced
plugin_<name>_* table enforcement, idempotent re-apply
* Three-phase lifecycle (Migrate → Init → Start) with panic-safe
wrappers around every plugin call; failure per plugin isolated,
core continues
* YAML config loader that preserves unknown top-level keys on round-trip
* Status store exposing /api/plugins/status JSON
* Restart helpers (Noop + SignalRestarter); graceful reload is
in-process for the demo
- internal/plugin/plugintest/ (~345 LOC):
* NopHost(t) with in-memory modernc.org/sqlite
* Run(t, plugin) full-lifecycle smoke helper
* Assertions: HasTool, HasAction, HasPanel, HasChannelType,
HasMigration, PluginStarted, PluginFailed
* ScopedSecrets that returns ErrSecretNotFound for cross-plugin
reads (satisfies SC-006)
- internal/plugins/demo/ (canonical showcase):
* Plugin that exercises every HasX capability (migrations, actions,
HTTP routes, web panel, lifecycle, config schema, stability)
* Own SQL migration creating plugin_demo_notes
* Embedded HTML panel that fetches notes via JS
* 4 unit tests covering smoke, full capability registration, action
handlers, and config-driven max_notes limit
- cmd/plugindemo/ (~290 LOC):
* Demo HTTP server wiring registry to chi
* Mounts /api/plugins/status, /api/admin/plugins/{name}/{enable,disable},
/api/actions/{name}, /api/plugins/<name>/* (per-plugin REST),
/ui/plugins/<name>/ (per-plugin UI)
* SIGHUP-triggered config reload + registry rebuild + mux swap
* SIGTERM/SIGINT graceful shutdown
- test/integration/ (~357 LOC, build-tag "integration"):
* 6 end-to-end tests against a spawned plugindemo binary
* Enable/disable round-trip with data preservation
* SIGHUP reload timing (measured 41 ms — SC-008 target is 2 s)
* Action-404 on disabled plugin, panel-404 on disabled plugin
* REST endpoints + UI panel reachable
Contract deviation: admin toggle endpoints moved from
/api/plugins/{name}/{enable,disable} to /api/admin/plugins/{name}/{...}
to avoid URL collision with chi per-plugin route mounts. rest.md updated.
Scope deferred to next session (mechanical follow-ups):
- Port internal/wiki/ to internal/plugins/wiki/
- Squash 26 migrations to schema/000_initial.sql
- Backup scripts for live kubic instance
- Remaining 9 plugin extractions
- Boundary-lint static analyzer
- Wire into cmd/synapbus/main.go
All unit + integration tests green. Chrome UI smoke test passes.
autonomous_summary.md carries the full verification record.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
98 lines
2.7 KiB
Go
98 lines
2.7 KiB
Go
package demo_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/synapbus/synapbus/internal/plugin"
|
|
"github.com/synapbus/synapbus/internal/plugin/plugintest"
|
|
"github.com/synapbus/synapbus/internal/plugins/demo"
|
|
)
|
|
|
|
// Smoke: plugin lifecycle runs end-to-end.
|
|
func TestDemoPlugin_Smoke(t *testing.T) {
|
|
plugintest.Run(t, demo.New())
|
|
}
|
|
|
|
// Full capability check through a Registry.
|
|
func TestDemoPlugin_AllCapabilitiesRegistered(t *testing.T) {
|
|
reg, err := plugin.NewRegistry(
|
|
[]plugin.Plugin{demo.New()},
|
|
&plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{
|
|
"demo": {Enabled: true},
|
|
}},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plugintest.RunRegistry(t, reg)
|
|
plugintest.HasAction(t, reg, "create_note")
|
|
plugintest.HasAction(t, reg, "list_notes")
|
|
plugintest.HasAction(t, reg, "get_note")
|
|
plugintest.HasPanel(t, reg, "demo")
|
|
plugintest.PluginStarted(t, reg, "demo")
|
|
}
|
|
|
|
// Action handlers work against the in-memory DB.
|
|
func TestDemoPlugin_Actions(t *testing.T) {
|
|
p := demo.New()
|
|
host := plugintest.NopHost(t)
|
|
ctx := context.Background()
|
|
if _, err := plugin.ApplyMigrations(ctx, host.DB, p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := p.Init(ctx, host); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// create
|
|
created, err := p.Actions()[0].Handler(ctx, map[string]any{"slug": "hello", "title": "Hello", "body": "hi"})
|
|
if err != nil {
|
|
t.Fatalf("create_note: %v", err)
|
|
}
|
|
m := created.(map[string]any)
|
|
if m["slug"] != "hello" {
|
|
t.Fatalf("unexpected create result: %+v", m)
|
|
}
|
|
// list
|
|
listed, err := p.Actions()[2].Handler(ctx, map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("list_notes: %v", err)
|
|
}
|
|
if listed.(map[string]any)["count"].(int) != 1 {
|
|
t.Fatalf("expected 1 note, got %+v", listed)
|
|
}
|
|
// duplicate slug rejected
|
|
if _, err := p.Actions()[0].Handler(ctx, map[string]any{"slug": "hello", "title": "Again"}); err == nil {
|
|
t.Fatal("expected duplicate slug to be rejected")
|
|
}
|
|
// get
|
|
got, err := p.Actions()[1].Handler(ctx, map[string]any{"slug": "hello"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("get returned nil")
|
|
}
|
|
}
|
|
|
|
func TestDemoPlugin_MaxNotesLimit(t *testing.T) {
|
|
p := demo.New()
|
|
host := plugintest.NopHost(t)
|
|
// Override the Host.Config so decodeConfig sees our limit.
|
|
host.Config = []byte(`{"max_notes": 1}`)
|
|
ctx := context.Background()
|
|
if _, err := plugin.ApplyMigrations(ctx, host.DB, p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := p.Init(ctx, host); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := p.Actions()[0].Handler(ctx, map[string]any{"slug": "a", "title": "A"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := p.Actions()[0].Handler(ctx, map[string]any{"slug": "b", "title": "B"}); err == nil {
|
|
t.Fatal("expected max_notes limit to reject second note")
|
|
}
|
|
}
|