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:
mirivlad 2026-06-07 22:58:26 +08:00
parent d83c8c80e1
commit 45cfe1b0a6
8 changed files with 340 additions and 106 deletions

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -10,9 +9,6 @@ import (
"strings" "strings"
"verstak/internal/core/config" "verstak/internal/core/config"
"verstak/internal/core/plugins"
lua "github.com/yuin/gopher-lua"
) )
// PluginDTO represents a discovered plugin with its current state. // 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. // SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
// Enable: marks plugin as enabled, then activates runtime (VM + scheduler). // Enable: marks plugin as enabled, activates runtime, THEN persists to config (only on success).
// Disable: deactivates runtime, then marks plugin as disabled. // Disable: deactivates runtime, then marks plugin as disabled in config.
func (a *App) SetPluginEnabled(name string, enabled bool) error { func (a *App) SetPluginEnabled(name string, enabled bool) error {
if a.plugins == nil { if a.plugins == nil {
return fmt.Errorf("plugin manager not ready") return fmt.Errorf("plugin manager not ready")
} }
if enabled { 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 { if err := a.plugins.Enable(name); err != nil {
return err 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 { } else {
// Deactivate runtime first, then disable // Deactivate runtime first, then disable
a.plugins.DeactivatePlugin(name) a.plugins.DeactivatePlugin(name)
if err := a.plugins.Disable(name); err != nil { if err := a.plugins.Disable(name); err != nil {
return err 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() appCfg, _ := config.LoadAppConfig()
if appCfg == nil { if appCfg == nil {
appCfg = config.DefaultAppConfig() appCfg = config.DefaultAppConfig()
} }
existing := make(map[string]bool)
for _, n := range appCfg.EnabledPlugins { for _, n := range appCfg.EnabledPlugins {
existing[n] = true if n == name {
return nil // already present
} }
if enabled {
existing[name] = true
} else {
delete(existing, name)
} }
appCfg.EnabledPlugins = make([]string, 0, len(existing)) appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, name)
for n := range existing { return config.SaveAppConfig(appCfg)
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, n)
}
if err := config.SaveAppConfig(appCfg); err != nil {
return fmt.Errorf("save config: %w", err)
} }
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. // GetPluginPanelHTML returns the HTML panel content for a plugin.
@ -269,59 +288,13 @@ func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string)
continue continue
} }
// Parse params into Lua value // Call via fully thread-safe LuaVM.CallFunctionJSON
luaArg, err := parseParamsToLua(vm, paramsJSON) // (JSON→Lua conversion happens under vm.mu)
if err != nil { return vm.CallFunctionJSON(segments, paramsJSON)
return "", fmt.Errorf("parse params: %w", err)
}
// Call via thread-safe, timeout-safe LuaVM.CallFunction
return vm.CallFunction(segments, luaArg)
} }
return "", fmt.Errorf("plugin %q not active or not found", pluginName) 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), &params); 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. // ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
func (a *App) ReloadPlugins() error { func (a *App) ReloadPlugins() error {
if a.plugins == nil { if a.plugins == nil {

View File

@ -19,7 +19,7 @@
background: #13131f; background: #13131f;
} }
</style> </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"> <link rel="stylesheet" crossorigin href="/assets/main-Cjkp2F09.css">
</head> </head>
<body> <body>

View File

@ -10,7 +10,7 @@
import SettingsWindow from './lib/SettingsWindow.svelte' import SettingsWindow from './lib/SettingsWindow.svelte'
import SyncStatus from './lib/SyncStatus.svelte' import SyncStatus from './lib/SyncStatus.svelte'
import TodayScreen from './lib/TodayScreen.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 AppHeader from './lib/AppHeader.svelte'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { actionIcon } from './lib/actionIcons.js' import { actionIcon } from './lib/actionIcons.js'
@ -3574,7 +3574,7 @@
</div> </div>
{:else if selectedSection && selectedSection.startsWith('plugin:')} {:else if selectedSection && selectedSection.startsWith('plugin:')}
<PluginPage sectionId={selectedSection} /> <CalendarPluginPage sectionId={selectedSection} />
{:else} {:else}
<div class="welcome"> <div class="welcome">

View File

@ -75,7 +75,7 @@
break break
default: default:
console.log('[PluginPage] Unknown iframe action:', msg.action) console.log('[CalendarPluginPage] Unknown iframe action:', msg.action)
} }
} }
@ -106,7 +106,7 @@
categories, categories,
}) })
} catch (e) { } catch (e) {
console.error('[PluginPage] loadCalendarData:', e) console.error('[CalendarPluginPage] loadCalendarData:', e)
} }
} }
@ -121,7 +121,7 @@
events, events,
}) })
} catch (e) { } catch (e) {
console.error('[PluginPage] get-events:', e) console.error('[CalendarPluginPage] get-events:', e)
} }
} }
@ -137,7 +137,7 @@
}) })
loadCalendarData() loadCalendarData()
} catch (e) { } catch (e) {
console.error('[PluginPage] create-event:', e) console.error('[CalendarPluginPage] create-event:', e)
postToIframe({ postToIframe({
source: 'verstak', source: 'verstak',
type: 'error', type: 'error',
@ -157,7 +157,7 @@
}) })
loadCalendarData() loadCalendarData()
} catch (e) { } catch (e) {
console.error('[PluginPage] update-event:', e) console.error('[CalendarPluginPage] update-event:', e)
} }
} }
@ -172,7 +172,7 @@
}) })
loadCalendarData() loadCalendarData()
} catch (e) { } catch (e) {
console.error('[PluginPage] delete-event:', e) console.error('[CalendarPluginPage] delete-event:', e)
} }
} }

View File

@ -1,6 +1,7 @@
package plugins package plugins
import ( import (
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@ -10,25 +11,23 @@ import (
// ActivatePlugin fully activates a plugin: creates Lua VM, loads main.lua, starts scheduler. // 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. // 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 { for i := range m.plugins {
p := &m.plugins[i] p := &m.plugins[i]
if p.Meta.Name != name || p.Active { if p.Meta.Name != name || p.Active {
continue continue
} }
if !p.Enabled { if !p.Enabled {
log.Printf("[plugins] %s: cannot activate — not enabled", name) return fmt.Errorf("plugin %q is not enabled", name)
return
} }
if p.HasInstall && !p.Installed { if p.HasInstall && !p.Installed {
log.Printf("[plugins] %s: cannot activate — not installed", name) return fmt.Errorf("plugin %q is not installed", name)
return
} }
vm, err := NewLuaVM(p) vm, err := NewLuaVM(p)
if err != nil { if err != nil {
log.Printf("[plugins] %s: activate VM error: %v", name, err) return fmt.Errorf("create VM for %q: %w", name, err)
return
} }
p.vm = vm p.vm = vm
if m.Services != nil { if m.Services != nil {
@ -38,28 +37,35 @@ func (m *Manager) ActivatePlugin(name string) {
mainPath := filepath.Join(p.Dir, "main.lua") mainPath := filepath.Join(p.Dir, "main.lua")
if _, err := os.Stat(mainPath); err == nil { if _, err := os.Stat(mainPath); err == nil {
if err := vm.LoadScript("main.lua"); 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) p.scheduler = NewScheduler(p, vm)
for _, bg := range p.Meta.Background { for _, bg := range p.Meta.Background {
if err := p.scheduler.AddTask(bg); err != nil { 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() p.scheduler.Start()
if hookName, ok := p.Meta.Hooks["on_init"]; ok { if hookName, ok := p.Meta.Hooks["on_init"]; ok {
if err := vm.CallHook(hookName); err != nil { 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) log.Printf("[plugins] %s: on_init error: %v", name, err)
} }
} }
p.Active = true p.Active = true
log.Printf("[plugins] %s: activated", name) 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. // DeactivatePlugin stops a plugin's runtime without removing it from enabled list.

View File

@ -2,6 +2,7 @@ package plugins
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -193,11 +194,11 @@ func (vm *LuaVM) DoString(src string) (string, error) {
return ret.String(), nil 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"]) // segments: pre-validated identifier segments (e.g. ["calendar", "create_event"])
// luaArg: pre-converted Lua argument (nil for no-arg calls) // paramsJSON: JSON string or "" / "{}" for no-arg calls.
// Returns the first return value as string, or error. func (vm *LuaVM) CallFunctionJSON(segments []string, paramsJSON string) (string, error) {
func (vm *LuaVM) CallFunction(segments []string, luaArg lua.LValue) (string, error) {
vm.mu.Lock() vm.mu.Lock()
defer vm.mu.Unlock() 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") 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), &params); err != nil {
return "", fmt.Errorf("invalid JSON params: %w", err)
}
luaArg = goToLua(vm.L, params)
}
// Push function and args // Push function and args
vm.L.Push(fn) vm.L.Push(fn)
if luaArg != nil { if luaArg != nil {
@ -247,7 +258,8 @@ func (vm *LuaVM) CallFunction(segments []string, luaArg lua.LValue) (string, err
return ret.String(), nil 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 { func (vm *LuaVM) LState() *lua.LState {
return vm.L return vm.L
} }
@ -257,6 +269,35 @@ func (p *Plugin) VM() *LuaVM {
return p.vm 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. // callWithTimeout runs a PCall with a timeout and returns the first LValue.
// nargs is the number of function arguments already on the stack. // nargs is the number of function arguments already on the stack.
// Must be called with vm.mu held. // Must be called with vm.mu held.

View File

@ -1191,10 +1191,10 @@ end
// Set short timeout // Set short timeout
vm.callTimeout = 1 * time.Second vm.callTimeout = 1 * time.Second
// CallFunction should timeout, not hang // CallFunctionJSON should timeout, not hang
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
_, err := vm.CallFunction([]string{"infinite_loop"}, nil) _, err := vm.CallFunctionJSON([]string{"infinite_loop"}, "")
if err == nil { if err == nil {
t.Error("expected timeout error from infinite_loop") t.Error("expected timeout error from infinite_loop")
} }
@ -1205,7 +1205,7 @@ end
case <-done: case <-done:
// Success — function returned with timeout error // Success — function returned with timeout error
case <-time.After(5 * time.Second): 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: // 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) { func TestFullLifecycle_EndToEnd(t *testing.T) {
root := setupPluginDir(t, map[string]*fsDir{ root := setupPluginDir(t, map[string]*fsDir{
"e2e": { "e2e": {
@ -1367,14 +1367,14 @@ func TestFullLifecycle_EndToEnd(t *testing.T) {
t.Fatalf("expected 1 active, got %d", len(mgr.Active())) 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() allPlugins := mgr.Plugins()
if len(allPlugins) != 1 { if len(allPlugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(allPlugins)) 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 { 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 if result != "2" { // counter was 1 after on_init, now 2
t.Errorf("increment() = %q, want %q", result, "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()))
}
}