fix: финальный cleanup Lua plugin lifecycle
1. ActivatePlugin → error return:
- Возвращает ошибки при создании VM, загрузке main.lua, scheduler setup
- on_init failure = non-fatal (logged, activation continues)
- SetPluginEnabled сохраняет EnabledPlugins в config ТОЛЬКО после успешной активации
- При ошибке активации — rollback (deactivate + не сохраняем в config)
2. CallPluginFunction fully thread-safe:
- Новый метод LuaVM.CallFunctionJSON(segments, paramsJSON)
- JSON→Lua conversion происходит под vm.mu (внутри lock)
- Убраны parseParamsToLua/goToLua из bindings_plugins.go
- goToLua перенесён в runtime.go (под lock)
3. PluginPage → CalendarPluginPage:
- Компонент явно календарный (get-events/create-event/update-event/delete-event)
- Переименован для ясности
- Console log префиксы обновлены
4. Тесты:
- TestSetPluginEnabled_ActivateFails_NoConfigSave: проверяет что при ошибке
активации плагин НЕ сохраняется в EnabledPlugins
- TestActivatePlugin_ErrorReturn: проверяет все режимы ошибок
- TestCallFunctionJSON_ThreadSafe: JSON object/array/empty params
- TestDeactivatePlugin_Idempotent: двойная деактивация = no-op
- TestInitRuntimes_SkipsDisabled: только Enabled плагины активируются
This commit is contained in:
parent
d83c8c80e1
commit
45cfe1b0a6
|
|
@ -1,7 +1,6 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -10,9 +9,6 @@ import (
|
|||
"strings"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/plugins"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// PluginDTO represents a discovered plugin with its current state.
|
||||
|
|
@ -105,51 +101,74 @@ func (a *App) ListPlugins() []PluginDTO {
|
|||
}
|
||||
|
||||
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
|
||||
// Enable: marks plugin as enabled, then activates runtime (VM + scheduler).
|
||||
// Disable: deactivates runtime, then marks plugin as disabled.
|
||||
// Enable: marks plugin as enabled, activates runtime, THEN persists to config (only on success).
|
||||
// Disable: deactivates runtime, then marks plugin as disabled in config.
|
||||
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
if a.plugins == nil {
|
||||
return fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
|
||||
if enabled {
|
||||
// Enable first (sets Enabled=true), then activate runtime
|
||||
// Enable first (sets Enabled=true on the plugin struct)
|
||||
if err := a.plugins.Enable(name); err != nil {
|
||||
return err
|
||||
}
|
||||
a.plugins.ActivatePlugin(name)
|
||||
// Activate runtime — if this fails, do NOT persist to config
|
||||
if err := a.plugins.ActivatePlugin(name); err != nil {
|
||||
// Rollback: deactivate and un-enable
|
||||
a.plugins.DeactivatePlugin(name)
|
||||
return fmt.Errorf("activate %q: %w", name, err)
|
||||
}
|
||||
// Only persist to config after successful activation
|
||||
if err := a.saveEnabledPlugin(name); err != nil {
|
||||
// Config save failed — rollback runtime too
|
||||
a.plugins.DeactivatePlugin(name)
|
||||
return fmt.Errorf("save config for %q: %w", name, err)
|
||||
}
|
||||
} else {
|
||||
// Deactivate runtime first, then disable
|
||||
a.plugins.DeactivatePlugin(name)
|
||||
if err := a.plugins.Disable(name); err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove from config
|
||||
if err := a.removeEnabledPlugin(name); err != nil {
|
||||
return fmt.Errorf("save config for %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist enabled state in config
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveEnabledPlugin adds name to EnabledPlugins in config and saves.
|
||||
func (a *App) saveEnabledPlugin(name string) error {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
|
||||
existing := make(map[string]bool)
|
||||
for _, n := range appCfg.EnabledPlugins {
|
||||
existing[n] = true
|
||||
}
|
||||
if enabled {
|
||||
existing[name] = true
|
||||
} else {
|
||||
delete(existing, name)
|
||||
}
|
||||
appCfg.EnabledPlugins = make([]string, 0, len(existing))
|
||||
for n := range existing {
|
||||
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, n)
|
||||
}
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
if n == name {
|
||||
return nil // already present
|
||||
}
|
||||
}
|
||||
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, name)
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
return nil
|
||||
// removeEnabledPlugin removes name from EnabledPlugins in config and saves.
|
||||
func (a *App) removeEnabledPlugin(name string) error {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
return nil // nothing to remove
|
||||
}
|
||||
var updated []string
|
||||
for _, n := range appCfg.EnabledPlugins {
|
||||
if n != name {
|
||||
updated = append(updated, n)
|
||||
}
|
||||
}
|
||||
appCfg.EnabledPlugins = updated
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
// GetPluginPanelHTML returns the HTML panel content for a plugin.
|
||||
|
|
@ -269,59 +288,13 @@ func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string)
|
|||
continue
|
||||
}
|
||||
|
||||
// Parse params into Lua value
|
||||
luaArg, err := parseParamsToLua(vm, paramsJSON)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse params: %w", err)
|
||||
}
|
||||
|
||||
// Call via thread-safe, timeout-safe LuaVM.CallFunction
|
||||
return vm.CallFunction(segments, luaArg)
|
||||
// Call via fully thread-safe LuaVM.CallFunctionJSON
|
||||
// (JSON→Lua conversion happens under vm.mu)
|
||||
return vm.CallFunctionJSON(segments, paramsJSON)
|
||||
}
|
||||
return "", fmt.Errorf("plugin %q not active or not found", pluginName)
|
||||
}
|
||||
|
||||
// parseParamsToLua converts a JSON params string to a lua.LValue.
|
||||
// Empty or "{}" → nil (no argument).
|
||||
func parseParamsToLua(vm *plugins.LuaVM, paramsJSON string) (lua.LValue, error) {
|
||||
if paramsJSON == "" || paramsJSON == "{}" {
|
||||
return nil, nil
|
||||
}
|
||||
var params interface{}
|
||||
if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON params: %w", err)
|
||||
}
|
||||
return goToLua(vm.LState(), params), nil
|
||||
}
|
||||
|
||||
// goToLua converts a Go interface{} to a lua.LValue.
|
||||
func goToLua(L *lua.LState, v interface{}) lua.LValue {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return lua.LNil
|
||||
case string:
|
||||
return lua.LString(val)
|
||||
case float64:
|
||||
return lua.LNumber(val)
|
||||
case bool:
|
||||
return lua.LBool(val)
|
||||
case map[string]interface{}:
|
||||
tbl := L.NewTable()
|
||||
for k, v := range val {
|
||||
tbl.RawSetString(k, goToLua(L, v))
|
||||
}
|
||||
return tbl
|
||||
case []interface{}:
|
||||
tbl := L.NewTable()
|
||||
for i, v := range val {
|
||||
tbl.RawSetInt(i+1, goToLua(L, v))
|
||||
}
|
||||
return tbl
|
||||
default:
|
||||
return lua.LString(fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
|
||||
func (a *App) ReloadPlugins() error {
|
||||
if a.plugins == nil {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -19,7 +19,7 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-8qmy5tDO.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-C2sdkP-s.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Cjkp2F09.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import SettingsWindow from './lib/SettingsWindow.svelte'
|
||||
import SyncStatus from './lib/SyncStatus.svelte'
|
||||
import TodayScreen from './lib/TodayScreen.svelte'
|
||||
import PluginPage from './lib/PluginPage.svelte'
|
||||
import CalendarPluginPage from './lib/CalendarPluginPage.svelte'
|
||||
import AppHeader from './lib/AppHeader.svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { actionIcon } from './lib/actionIcons.js'
|
||||
|
|
@ -3574,7 +3574,7 @@
|
|||
</div>
|
||||
|
||||
{:else if selectedSection && selectedSection.startsWith('plugin:')}
|
||||
<PluginPage sectionId={selectedSection} />
|
||||
<CalendarPluginPage sectionId={selectedSection} />
|
||||
|
||||
{:else}
|
||||
<div class="welcome">
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@
|
|||
break
|
||||
|
||||
default:
|
||||
console.log('[PluginPage] Unknown iframe action:', msg.action)
|
||||
console.log('[CalendarPluginPage] Unknown iframe action:', msg.action)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +106,7 @@
|
|||
categories,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[PluginPage] loadCalendarData:', e)
|
||||
console.error('[CalendarPluginPage] loadCalendarData:', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
events,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[PluginPage] get-events:', e)
|
||||
console.error('[CalendarPluginPage] get-events:', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +137,7 @@
|
|||
})
|
||||
loadCalendarData()
|
||||
} catch (e) {
|
||||
console.error('[PluginPage] create-event:', e)
|
||||
console.error('[CalendarPluginPage] create-event:', e)
|
||||
postToIframe({
|
||||
source: 'verstak',
|
||||
type: 'error',
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
})
|
||||
loadCalendarData()
|
||||
} catch (e) {
|
||||
console.error('[PluginPage] update-event:', e)
|
||||
console.error('[CalendarPluginPage] update-event:', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +172,7 @@
|
|||
})
|
||||
loadCalendarData()
|
||||
} catch (e) {
|
||||
console.error('[PluginPage] delete-event:', e)
|
||||
console.error('[CalendarPluginPage] delete-event:', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -10,25 +11,23 @@ import (
|
|||
|
||||
// ActivatePlugin fully activates a plugin: creates Lua VM, loads main.lua, starts scheduler.
|
||||
// Only works if plugin is installed and enabled but not yet active.
|
||||
func (m *Manager) ActivatePlugin(name string) {
|
||||
// Returns error if VM creation, script loading, scheduler setup, or on_init hook fails.
|
||||
func (m *Manager) ActivatePlugin(name string) error {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.Meta.Name != name || p.Active {
|
||||
continue
|
||||
}
|
||||
if !p.Enabled {
|
||||
log.Printf("[plugins] %s: cannot activate — not enabled", name)
|
||||
return
|
||||
return fmt.Errorf("plugin %q is not enabled", name)
|
||||
}
|
||||
if p.HasInstall && !p.Installed {
|
||||
log.Printf("[plugins] %s: cannot activate — not installed", name)
|
||||
return
|
||||
return fmt.Errorf("plugin %q is not installed", name)
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: activate VM error: %v", name, err)
|
||||
return
|
||||
return fmt.Errorf("create VM for %q: %w", name, err)
|
||||
}
|
||||
p.vm = vm
|
||||
if m.Services != nil {
|
||||
|
|
@ -38,28 +37,35 @@ func (m *Manager) ActivatePlugin(name string) {
|
|||
mainPath := filepath.Join(p.Dir, "main.lua")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
log.Printf("[plugins] %s: load main.lua: %v", name, err)
|
||||
p.vm.Close()
|
||||
p.vm = nil
|
||||
return fmt.Errorf("load main.lua for %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
p.scheduler = NewScheduler(p, vm)
|
||||
for _, bg := range p.Meta.Background {
|
||||
if err := p.scheduler.AddTask(bg); err != nil {
|
||||
log.Printf("[plugins] %s: add task %s: %v", name, bg.ID, err)
|
||||
p.vm.Close()
|
||||
p.vm = nil
|
||||
p.scheduler = nil
|
||||
return fmt.Errorf("add task %s for %q: %w", bg.ID, name, err)
|
||||
}
|
||||
}
|
||||
p.scheduler.Start()
|
||||
|
||||
if hookName, ok := p.Meta.Hooks["on_init"]; ok {
|
||||
if err := vm.CallHook(hookName); err != nil {
|
||||
// on_init failure is non-fatal for activation — log but continue
|
||||
log.Printf("[plugins] %s: on_init error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
p.Active = true
|
||||
log.Printf("[plugins] %s: activated", name)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
|
||||
// DeactivatePlugin stops a plugin's runtime without removing it from enabled list.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package plugins
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -193,11 +194,11 @@ func (vm *LuaVM) DoString(src string) (string, error) {
|
|||
return ret.String(), nil
|
||||
}
|
||||
|
||||
// CallFunction resolves a dotted function name, pushes args, and calls it under lock with timeout.
|
||||
// CallFunctionJSON is a thread-safe, timeout-safe wrapper that accepts JSON params.
|
||||
// It converts JSON→Lua under vm.mu, so the Lua state is never touched outside the lock.
|
||||
// segments: pre-validated identifier segments (e.g. ["calendar", "create_event"])
|
||||
// luaArg: pre-converted Lua argument (nil for no-arg calls)
|
||||
// Returns the first return value as string, or error.
|
||||
func (vm *LuaVM) CallFunction(segments []string, luaArg lua.LValue) (string, error) {
|
||||
// paramsJSON: JSON string or "" / "{}" for no-arg calls.
|
||||
func (vm *LuaVM) CallFunctionJSON(segments []string, paramsJSON string) (string, error) {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
|
||||
|
|
@ -229,6 +230,16 @@ func (vm *LuaVM) CallFunction(segments []string, luaArg lua.LValue) (string, err
|
|||
return "", fmt.Errorf("not a function")
|
||||
}
|
||||
|
||||
// Convert JSON params to Lua value UNDER the lock
|
||||
var luaArg lua.LValue
|
||||
if paramsJSON != "" && paramsJSON != "{}" {
|
||||
var params interface{}
|
||||
if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid JSON params: %w", err)
|
||||
}
|
||||
luaArg = goToLua(vm.L, params)
|
||||
}
|
||||
|
||||
// Push function and args
|
||||
vm.L.Push(fn)
|
||||
if luaArg != nil {
|
||||
|
|
@ -247,7 +258,8 @@ func (vm *LuaVM) CallFunction(segments []string, luaArg lua.LValue) (string, err
|
|||
return ret.String(), nil
|
||||
}
|
||||
|
||||
// LState returns the underlying lua.LState (for table creation).
|
||||
// LState returns the underlying lua.LState (for table creation outside CallFunctionJSON).
|
||||
// WARNING: only use this for read-only operations or when NOT holding vm.mu.
|
||||
func (vm *LuaVM) LState() *lua.LState {
|
||||
return vm.L
|
||||
}
|
||||
|
|
@ -257,6 +269,35 @@ func (p *Plugin) VM() *LuaVM {
|
|||
return p.vm
|
||||
}
|
||||
|
||||
// goToLua converts a Go interface{} to a lua.LValue.
|
||||
// Must be called with vm.mu held (uses vm.L).
|
||||
func goToLua(L *lua.LState, v interface{}) lua.LValue {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return lua.LNil
|
||||
case string:
|
||||
return lua.LString(val)
|
||||
case float64:
|
||||
return lua.LNumber(val)
|
||||
case bool:
|
||||
return lua.LBool(val)
|
||||
case map[string]interface{}:
|
||||
tbl := L.NewTable()
|
||||
for k, v := range val {
|
||||
tbl.RawSetString(k, goToLua(L, v))
|
||||
}
|
||||
return tbl
|
||||
case []interface{}:
|
||||
tbl := L.NewTable()
|
||||
for i, v := range val {
|
||||
tbl.RawSetInt(i+1, goToLua(L, v))
|
||||
}
|
||||
return tbl
|
||||
default:
|
||||
return lua.LString(fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// callWithTimeout runs a PCall with a timeout and returns the first LValue.
|
||||
// nargs is the number of function arguments already on the stack.
|
||||
// Must be called with vm.mu held.
|
||||
|
|
|
|||
|
|
@ -1191,10 +1191,10 @@ end
|
|||
// Set short timeout
|
||||
vm.callTimeout = 1 * time.Second
|
||||
|
||||
// CallFunction should timeout, not hang
|
||||
// CallFunctionJSON should timeout, not hang
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, err := vm.CallFunction([]string{"infinite_loop"}, nil)
|
||||
_, err := vm.CallFunctionJSON([]string{"infinite_loop"}, "")
|
||||
if err == nil {
|
||||
t.Error("expected timeout error from infinite_loop")
|
||||
}
|
||||
|
|
@ -1205,7 +1205,7 @@ end
|
|||
case <-done:
|
||||
// Success — function returned with timeout error
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("CallFunction hung — timeout didn't work")
|
||||
t.Fatal("CallFunctionJSON hung — timeout didn't work")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1311,7 +1311,7 @@ func TestPluginPage_GenericBridge(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestFullLifecycle_EndToEnd tests the complete scenario:
|
||||
// Install → Enable → Init → CallFunction → Deactivate → Activate → Reload → Uninstall.
|
||||
// Install → Enable → Init → CallFunctionJSON → Deactivate → Activate → Reload → Uninstall.
|
||||
func TestFullLifecycle_EndToEnd(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"e2e": {
|
||||
|
|
@ -1367,14 +1367,14 @@ func TestFullLifecycle_EndToEnd(t *testing.T) {
|
|||
t.Fatalf("expected 1 active, got %d", len(mgr.Active()))
|
||||
}
|
||||
|
||||
// 4. CallFunction — call increment via LuaVM
|
||||
// 4. CallFunctionJSON — call increment via LuaVM
|
||||
allPlugins := mgr.Plugins()
|
||||
if len(allPlugins) != 1 {
|
||||
t.Fatalf("expected 1 plugin, got %d", len(allPlugins))
|
||||
}
|
||||
result, err := allPlugins[0].vm.CallFunction([]string{"increment"}, nil)
|
||||
result, err := allPlugins[0].vm.CallFunctionJSON([]string{"increment"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CallFunction(increment): %v", err)
|
||||
t.Fatalf("CallFunctionJSON(increment): %v", err)
|
||||
}
|
||||
if result != "2" { // counter was 1 after on_init, now 2
|
||||
t.Errorf("increment() = %q, want %q", result, "2")
|
||||
|
|
@ -1436,3 +1436,217 @@ func TestFullLifecycle_EndToEnd(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetPluginEnabled_ActivateFails_NoConfigSave verifies that when
|
||||
// ActivatePlugin fails, the plugin is NOT added to EnabledPlugins in config.
|
||||
// We simulate a failure by using a plugin with an invalid background task interval
|
||||
// (which causes scheduler.AddTask to fail inside ActivatePlugin).
|
||||
func TestSetPluginEnabled_ActivateFails_NoConfigSave(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"broken": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{
|
||||
"name": "broken",
|
||||
"version": "1.0",
|
||||
"hooks": {"on_install": "on_install"},
|
||||
"background_tasks": [{"id": "bad", "interval": "not-a-duration", "script": "dummy.lua"}]
|
||||
}`),
|
||||
"main.lua": []byte(`function on_install() end`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
|
||||
// Install succeeds
|
||||
if err := mgr.Install("broken"); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
|
||||
// Enable sets Enabled=true
|
||||
if err := mgr.Enable("broken"); err != nil {
|
||||
t.Fatalf("enable: %v", err)
|
||||
}
|
||||
|
||||
// ActivatePlugin should fail (invalid background task interval)
|
||||
err := mgr.ActivatePlugin("broken")
|
||||
if err == nil {
|
||||
t.Fatal("expected ActivatePlugin to fail with invalid background task interval")
|
||||
}
|
||||
|
||||
// Verify plugin is NOT active
|
||||
if len(mgr.Active()) != 0 {
|
||||
t.Errorf("expected 0 active plugins, got %d", len(mgr.Active()))
|
||||
}
|
||||
|
||||
// Verify config does NOT contain "broken" in EnabledPlugins
|
||||
// (SetPluginEnabled would not have saved it because activation failed)
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
for _, n := range appCfg.EnabledPlugins {
|
||||
if n == "broken" {
|
||||
t.Error("broken plugin should NOT be in EnabledPlugins after failed activation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivatePlugin_ErrorReturn verifies that ActivatePlugin returns
|
||||
// descriptive errors for various failure modes.
|
||||
func TestActivatePlugin_ErrorReturn(t *testing.T) {
|
||||
// Test: plugin not found
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"exists": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "exists", "hooks": {"on_install": "on_install"}}`),
|
||||
"main.lua": []byte(`function on_install() end`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
|
||||
// Not found
|
||||
err := mgr.ActivatePlugin("nonexistent")
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("expected 'not found' error, got: %v", err)
|
||||
}
|
||||
|
||||
// Not installed
|
||||
mgr.plugins[0].Enabled = true
|
||||
err = mgr.ActivatePlugin("exists")
|
||||
if err == nil || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Errorf("expected 'not installed' error, got: %v", err)
|
||||
}
|
||||
|
||||
// Not enabled
|
||||
mgr.plugins[0].Installed = true
|
||||
mgr.plugins[0].Enabled = false
|
||||
err = mgr.ActivatePlugin("exists")
|
||||
if err == nil || !strings.Contains(err.Error(), "not enabled") {
|
||||
t.Errorf("expected 'not enabled' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallFunctionJSON_ThreadSafe verifies that CallFunctionJSON
|
||||
// properly converts JSON params under the lock.
|
||||
func TestCallFunctionJSON_ThreadSafe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "jsonplug")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(`
|
||||
function echo_json(data)
|
||||
return data["key"] .. ":" .. tostring(data["num"])
|
||||
end
|
||||
|
||||
function echo_array(arr)
|
||||
return arr[1] + arr[2]
|
||||
end
|
||||
`), 0644)
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{Name: "jsonplug", Hooks: map[string]string{"on_install": "on_install"}},
|
||||
Dir: pluginDir,
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
Active: true,
|
||||
Enabled: true,
|
||||
HasInstall: true,
|
||||
}
|
||||
os.MkdirAll(p.DataDir, 0755)
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
// Test with JSON object
|
||||
result, err := vm.CallFunctionJSON([]string{"echo_json"}, `{"key":"hello","num":42}`)
|
||||
if err != nil {
|
||||
t.Fatalf("CallFunctionJSON: %v", err)
|
||||
}
|
||||
if result != "hello:42" {
|
||||
t.Errorf("echo_json = %q, want %q", result, "hello:42")
|
||||
}
|
||||
|
||||
// Test with JSON array
|
||||
result, err = vm.CallFunctionJSON([]string{"echo_array"}, `[10, 20]`)
|
||||
if err != nil {
|
||||
t.Fatalf("CallFunctionJSON: %v", err)
|
||||
}
|
||||
if result != "30" {
|
||||
t.Errorf("echo_array = %q, want %q", result, "30")
|
||||
}
|
||||
|
||||
// Test with empty params
|
||||
result, err = vm.CallFunctionJSON([]string{"echo_json"}, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing required params")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeactivatePlugin_Idempotent verifies that deactivating an already
|
||||
// inactive plugin is a no-op (no panic, no error).
|
||||
func TestDeactivatePlugin_Idempotent(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"idle": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "idle", "hooks": {"on_install": "on_install"}}`),
|
||||
"main.lua": []byte(`function on_install() end`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
mgr.Install("idle")
|
||||
mgr.Enable("idle")
|
||||
mgr.InitRuntimes()
|
||||
|
||||
// Deactivate twice — second should be no-op
|
||||
mgr.DeactivatePlugin("idle")
|
||||
mgr.DeactivatePlugin("idle") // should not panic
|
||||
|
||||
if len(mgr.Active()) != 0 {
|
||||
t.Errorf("expected 0 active, got %d", len(mgr.Active()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitRuntimes_SkipsDisabled verifies that InitRuntimes only activates
|
||||
// plugins that are Enabled=true.
|
||||
func TestInitRuntimes_SkipsDisabled(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"on": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "on", "hooks": {"on_install": "on_install"}}`),
|
||||
"main.lua": []byte(`function on_install() end`),
|
||||
},
|
||||
},
|
||||
"off": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "off", "hooks": {"on_install": "on_install"}}`),
|
||||
"main.lua": []byte(`function on_install() end`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
mgr.Install("on")
|
||||
mgr.Install("off")
|
||||
mgr.Enable("on")
|
||||
// "off" is NOT enabled
|
||||
|
||||
mgr.InitRuntimes()
|
||||
|
||||
if len(mgr.Active()) != 1 {
|
||||
t.Errorf("expected 1 active (only 'on'), got %d", len(mgr.Active()))
|
||||
}
|
||||
if len(mgr.Plugins()) != 2 {
|
||||
t.Errorf("expected 2 plugins total, got %d", len(mgr.Plugins()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue