Files
Algis DumbrisandClaude Opus 4.7 494e8f83e9 feat(plugin): plugin framework + plugintest + demo plugin + integration tests
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>
2026-04-19 07:14:27 +03:00

119 lines
3.2 KiB
Go

// Package plugintest provides a Host implementation backed by in-memory
// SQLite and no-op stubs for every core dependency, plus a Run helper that
// exercises a plugin's full lifecycle.
package plugintest
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
"os"
"testing"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/plugin"
)
// NopHost returns an in-memory Host suitable for unit tests.
// The database, data directory, event bus, and metrics registry are all
// scoped to the calling test (via t.TempDir and t.Cleanup).
func NopHost(t *testing.T) plugin.Host {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
dir := t.TempDir()
return plugin.Host{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
DB: db,
Messenger: &nopMessenger{},
Channels: &nopChannels{},
Attachments: &nopAttachments{},
Search: &nopSearch{},
Secrets: NewScopedSecrets(""),
Events: plugin.NewEventBus(),
Config: json.RawMessage(`{}`),
DataDir: dir,
Tracer: &nopTracer{},
Metrics: &nopMetrics{},
DefaultOwner: &plugin.Owner{
ID: 1, Username: "testowner", Email: "owner@example.test",
},
BaseURL: "http://localhost:8080",
}
}
// HostWithDB returns a Host backed by an existing *sql.DB (useful for
// integration tests where multiple plugins share state).
func HostWithDB(t *testing.T, db *sql.DB, pluginName string) plugin.Host {
t.Helper()
dir := t.TempDir()
return plugin.Host{
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})).With("plugin", pluginName),
DB: db,
Messenger: &nopMessenger{},
Channels: &nopChannels{},
Attachments: &nopAttachments{},
Search: &nopSearch{},
Secrets: NewScopedSecrets(pluginName),
Events: plugin.NewEventBus(),
Config: json.RawMessage(`{}`),
DataDir: dir,
Tracer: &nopTracer{},
Metrics: &nopMetrics{},
DefaultOwner: &plugin.Owner{ID: 1, Username: "testowner", Email: "owner@example.test"},
BaseURL: "http://localhost:8080",
}
}
// --- ambient stubs ---
type nopMessenger struct{}
func (nopMessenger) SendDM(ctx context.Context, toAgent, body string, priority int) error {
return nil
}
type nopChannels struct{}
func (nopChannels) Post(ctx context.Context, channel, body string) (int64, error) {
return 0, nil
}
type nopAttachments struct{}
func (nopAttachments) Put(ctx context.Context, r []byte, mime string) (string, error) {
return "", nil
}
func (nopAttachments) Get(ctx context.Context, hash string) ([]byte, string, error) {
return nil, "", nil
}
type nopSearch struct{}
func (nopSearch) Query(ctx context.Context, text string, limit int) ([]plugin.SearchHit, error) {
return nil, nil
}
type nopTracer struct{}
func (nopTracer) Start(ctx context.Context, name string) (context.Context, plugin.TraceSpan) {
return ctx, nopSpan{}
}
type nopSpan struct{}
func (nopSpan) End() {}
func (nopSpan) SetAttribute(key string, value any) {}
type nopMetrics struct{}
func (nopMetrics) Register(c any) error { return nil }