Files
Test cb8a3fe12a feat(T3.1): implement custom skill plugin system
- Add internal/plugins package for custom skill plugins
- Implement SkillPlugin interface for extensibility
- Implement PluginRegistry for plugin management
- Support plugin:// URL scheme for plugin references
- Register/unregister plugins dynamically
- Enable/disable plugin control
- Execution logging with timing metrics
- Plugin metadata tracking (version, author, config)
- PluginLoader for lifecycle management
- Load plugins from files and directories
- Reload plugins without restart
- Statistics tracking (executions, success rate)
- 48 plugin tests, all passing

Features:
- SkillPlugin interface (Name, Version, Execute, Validate, Description)
- PluginRegistry for central registration and execution
- plugin:// URL scheme for plugin references
- Dynamic loading from JSON config files
- Plugin enable/disable control
- Execution history tracking
- Timing metrics for performance monitoring
- Configuration storage per plugin
- Metadata tracking (version, author, description)
- Plugin statistics (total runs, success rate, avg time)

Registry Operations:
- Register(plugin, author, config) - register new plugin
- Unregister(name) - remove plugin
- Execute(name, input) - execute by name
- Get(name) - retrieve plugin reference
- ListPlugins() - enumerate all plugins
- EnablePlugin(name) / DisablePlugin(name)
- GetExecutionLog(name) - timing and result history
- ResolvePluginURL(url) - resolve plugin:// URLs

Loader Operations:
- RegisterLoadedPlugin() - add to registry
- UnloadPlugin() - remove from registry
- ReloadPlugin() - reinitialize without restart
- LoadPluginDirectory() - batch load from directory
- ExecutePlugin() - execute through loader
- GetLoadedPlugins() - enumerate loaded
- IsPluginLoaded() - check status
- Close() - shutdown all plugins

URL Scheme:
- plugin://plugin-name - reference custom plugin
- Enables flexible skill resolution
- Supports custom activities beyond pi clone

Plugin Metadata:
- Name, Version, Author
- Description, URL, Config
- LoadedAt timestamp, Enabled flag
- Config is arbitrary map[string]interface{}

Execution Tracking:
- Timestamp of execution
- Input and output data
- Success/failure status
- Duration measurement
- Error messages preserved

Test Coverage:
- 48 plugin tests (registry + loader)
- Plugin registration/unregistration
- Execution success and failure cases
- Enable/disable control
- Logging and timing verification
- URL resolution testing
- Directory loading tests
- Configuration persistence
- Statistics accuracy
- Concurrent safety (RWMutex)

Performance:
- Fast plugin lookup (O(1) hash map)
- Minimal overhead for execution
- Efficient logging with reuse
- Scalable to 100s of plugins

Next: T3.2 (Workflow templates)
2026-08-23 17:30:05 -07:00

409 lines
9.7 KiB
Go

package plugins
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// MockPlugin is a test plugin implementation
type MockPlugin struct {
name string
version string
description string
shouldFail bool
shouldWait time.Duration
}
func (mp *MockPlugin) Name() string {
return mp.name
}
func (mp *MockPlugin) Version() string {
return mp.version
}
func (mp *MockPlugin) Description() string {
return mp.description
}
func (mp *MockPlugin) Execute(input map[string]interface{}) (map[string]interface{}, error) {
if mp.shouldWait > 0 {
time.Sleep(mp.shouldWait)
}
if mp.shouldFail {
return nil, fmt.Errorf("plugin execution failed")
}
return map[string]interface{}{
"result": "success",
"input": input,
}, nil
}
func (mp *MockPlugin) Validate() error {
if mp.name == "" {
return fmt.Errorf("plugin name cannot be empty")
}
return nil
}
func TestNewPluginRegistry(t *testing.T) {
registry := NewPluginRegistry()
assert.NotNil(t, registry)
assert.Equal(t, 0, registry.stats.TotalPlugins)
}
func TestRegisterPlugin(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{
name: "test-plugin",
version: "1.0.0",
description: "Test plugin",
}
err := registry.Register(plugin, "test-author", nil)
assert.NoError(t, err)
assert.Equal(t, 1, registry.stats.TotalPlugins)
}
func TestRegisterPluginNil(t *testing.T) {
registry := NewPluginRegistry()
err := registry.Register(nil, "test-author", nil)
assert.Error(t, err)
}
func TestRegisterDuplicatePlugin(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{
name: "test-plugin",
version: "1.0.0",
}
registry.Register(plugin, "author", nil)
err := registry.Register(plugin, "author", nil)
assert.Error(t, err)
}
func TestUnregisterPlugin(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
assert.Equal(t, 1, registry.stats.TotalPlugins)
err := registry.Unregister("test-plugin")
assert.NoError(t, err)
assert.Equal(t, 0, registry.stats.TotalPlugins)
}
func TestExecutePlugin(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
input := map[string]interface{}{"key": "value"}
output, err := registry.Execute("test-plugin", input)
assert.NoError(t, err)
assert.NotNil(t, output)
assert.Equal(t, "success", output["result"])
}
func TestExecutePluginNotFound(t *testing.T) {
registry := NewPluginRegistry()
_, err := registry.Execute("nonexistent", map[string]interface{}{})
assert.Error(t, err)
}
func TestExecutePluginDisabled(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
registry.DisablePlugin("test-plugin")
_, err := registry.Execute("test-plugin", map[string]interface{}{})
assert.Error(t, err)
}
func TestExecutePluginFailure(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{
name: "test-plugin",
version: "1.0.0",
shouldFail: true,
}
registry.Register(plugin, "author", nil)
_, err := registry.Execute("test-plugin", map[string]interface{}{})
assert.Error(t, err)
assert.Equal(t, 1, registry.stats.FailedExecutions)
}
func TestGetPlugin(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
retrieved, found := registry.Get("test-plugin")
assert.True(t, found)
assert.Equal(t, "test-plugin", retrieved.Name())
}
func TestGetPluginNotFound(t *testing.T) {
registry := NewPluginRegistry()
_, found := registry.Get("nonexistent")
assert.False(t, found)
}
func TestGetMetadata(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{
name: "test-plugin",
version: "1.0.0",
description: "Test description",
}
registry.Register(plugin, "test-author", nil)
meta, found := registry.GetMetadata("test-plugin")
assert.True(t, found)
assert.Equal(t, "test-plugin", meta.Name)
assert.Equal(t, "1.0.0", meta.Version)
assert.Equal(t, "test-author", meta.Author)
assert.Equal(t, "plugin://test-plugin", meta.URL)
assert.True(t, meta.Enabled)
}
func TestListPlugins(t *testing.T) {
registry := NewPluginRegistry()
for i := 0; i < 3; i++ {
plugin := &MockPlugin{
name: fmt.Sprintf("plugin-%d", i),
version: "1.0.0",
}
registry.Register(plugin, "author", nil)
}
plugins := registry.ListPlugins()
assert.Equal(t, 3, len(plugins))
}
func TestEnableDisablePlugin(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
assert.Equal(t, 1, registry.stats.EnabledPlugins)
registry.DisablePlugin("test-plugin")
assert.Equal(t, 0, registry.stats.EnabledPlugins)
registry.EnablePlugin("test-plugin")
assert.Equal(t, 1, registry.stats.EnabledPlugins)
}
func TestGetExecutionLog(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
registry.Execute("test-plugin", map[string]interface{}{})
registry.Execute("test-plugin", map[string]interface{}{})
log := registry.GetExecutionLog("test-plugin")
assert.Equal(t, 2, len(log))
}
func TestExecutionLogSuccess(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
registry.Execute("test-plugin", map[string]interface{}{})
log := registry.GetExecutionLog("test-plugin")
assert.Equal(t, 1, len(log))
assert.True(t, log[0].Success)
assert.Nil(t, log[0].Error)
}
func TestExecutionLogFailure(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{
name: "test-plugin",
version: "1.0.0",
shouldFail: true,
}
registry.Register(plugin, "author", nil)
registry.Execute("test-plugin", map[string]interface{}{})
log := registry.GetExecutionLog("test-plugin")
assert.Equal(t, 1, len(log))
assert.False(t, log[0].Success)
assert.NotNil(t, log[0].Error)
}
func TestGetStats(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
registry.Execute("test-plugin", map[string]interface{}{})
registry.Execute("test-plugin", map[string]interface{}{})
stats := registry.GetStats()
assert.Equal(t, 1, stats.TotalPlugins)
assert.Equal(t, 2, stats.TotalExecutions)
assert.Equal(t, 2, stats.SuccessfulExecutions)
}
func TestResolvePluginURL(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
resolved, err := registry.ResolvePluginURL("plugin://test-plugin")
assert.NoError(t, err)
assert.Equal(t, "test-plugin", resolved.Name())
}
func TestResolvePluginURLInvalid(t *testing.T) {
registry := NewPluginRegistry()
_, err := registry.ResolvePluginURL("http://example.com")
assert.Error(t, err)
}
func TestResolvePluginURLNotFound(t *testing.T) {
registry := NewPluginRegistry()
_, err := registry.ResolvePluginURL("plugin://nonexistent")
assert.Error(t, err)
}
func TestClear(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
assert.Equal(t, 1, registry.stats.TotalPlugins)
registry.Clear()
assert.Equal(t, 0, registry.stats.TotalPlugins)
}
func TestIsPluginURL(t *testing.T) {
assert.True(t, IsPluginURL("plugin://test"))
assert.False(t, IsPluginURL("http://test"))
assert.False(t, IsPluginURL("file://test"))
}
func TestExtractPluginName(t *testing.T) {
name := ExtractPluginName("plugin://test-plugin")
assert.Equal(t, "test-plugin", name)
name = ExtractPluginName("http://test")
assert.Equal(t, "", name)
}
func TestExecutionTiming(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{
name: "test-plugin",
version: "1.0.0",
shouldWait: 50 * time.Millisecond,
}
registry.Register(plugin, "author", nil)
registry.Execute("test-plugin", map[string]interface{}{})
log := registry.GetExecutionLog("test-plugin")
assert.Greater(t, log[0].Duration, 40*time.Millisecond)
}
func TestMultiplePlugins(t *testing.T) {
registry := NewPluginRegistry()
for i := 0; i < 5; i++ {
plugin := &MockPlugin{
name: fmt.Sprintf("plugin-%d", i),
version: "1.0.0",
}
registry.Register(plugin, "author", nil)
}
assert.Equal(t, 5, registry.stats.TotalPlugins)
for i := 0; i < 5; i++ {
registry.Execute(fmt.Sprintf("plugin-%d", i), map[string]interface{}{})
}
stats := registry.GetStats()
assert.Equal(t, 5, stats.TotalExecutions)
}
func TestPluginConfig(t *testing.T) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
config := map[string]interface{}{
"setting1": "value1",
"setting2": 42,
}
registry.Register(plugin, "author", config)
meta, _ := registry.GetMetadata("test-plugin")
assert.NotNil(t, meta.Config)
assert.Equal(t, "value1", meta.Config["setting1"])
assert.Equal(t, 42, meta.Config["setting2"])
}
func BenchmarkRegisterPlugin(b *testing.B) {
registry := NewPluginRegistry()
for i := 0; i < b.N; i++ {
plugin := &MockPlugin{
name: fmt.Sprintf("plugin-%d", i),
version: "1.0.0",
}
registry.Register(plugin, "author", nil)
}
}
func BenchmarkExecutePlugin(b *testing.B) {
registry := NewPluginRegistry()
plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"}
registry.Register(plugin, "author", nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
registry.Execute("test-plugin", map[string]interface{}{})
}
}