package plugins import ( "os" "path/filepath" "testing" lua "github.com/yuin/gopher-lua" ) func TestLuaVM_HookCall(t *testing.T) { // Create a temp plugin directory dir := t.TempDir() pluginDir := filepath.Join(dir, "testplugin") if err := os.MkdirAll(pluginDir, 0755); err != nil { t.Fatal(err) } // Write a main.lua mainLua := ` function on_init() print("hello from lua") end function add(a, b) return a + b end ` if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil { t.Fatal(err) } p := &Plugin{ Meta: Meta{ Name: "testplugin", Hooks: map[string]string{ "on_init": "on_init", }, }, Dir: pluginDir, Active: true, } vm, err := NewLuaVM(p) if err != nil { t.Fatalf("NewLuaVM: %v", err) } defer vm.Close() // Load main.lua if err := vm.LoadScript("main.lua"); err != nil { t.Fatalf("LoadScript: %v", err) } // Call on_init hook (should not error) if err := vm.CallHook("on_init"); err != nil { t.Fatalf("CallHook(on_init): %v", err) } // Call a function with args and check result // Need to use CallHookWithResult for that result, err := vm.CallHookWithResult("add", lua.LNumber(3), lua.LNumber(4)) if err != nil { t.Fatalf("CallHookWithResult(add): %v", err) } if num, ok := result.(lua.LNumber); !ok { t.Fatalf("expected LNumber, got %T", result) } else if float64(num) != 7 { t.Fatalf("expected 7, got %v", num) } } func TestLuaVM_MissingHook(t *testing.T) { dir := t.TempDir() pluginDir := filepath.Join(dir, "nohooks") if err := os.MkdirAll(pluginDir, 0755); err != nil { t.Fatal(err) } // Write minimal main.lua (no on_shutdown function) mainLua := `function on_init() end` if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil { t.Fatal(err) } p := &Plugin{ Meta: Meta{ Name: "nohooks", Hooks: map[string]string{ "on_shutdown": "on_shutdown", }, }, Dir: pluginDir, Active: true, } 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) } // Calling a missing hook should return nil error (hooks are optional) if err := vm.CallHook("on_shutdown"); err != nil { t.Fatalf("CallHook(on_shutdown) on missing function: %v", err) } } func TestLuaVM_Sandbox(t *testing.T) { dir := t.TempDir() pluginDir := filepath.Join(dir, "sandbox") if err := os.MkdirAll(pluginDir, 0755); err != nil { t.Fatal(err) } // Verify dangerous functions are removed mainLua := ` function test_sandbox() local ok, err = pcall(function() return dofile("/etc/passwd") end) return ok, err -- dofile should be nil, so pcall returns false + error end ` if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil { t.Fatal(err) } p := &Plugin{ Meta: Meta{Name: "sandbox"}, Dir: pluginDir, Active: true, } 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) } result, err := vm.CallHookWithResult("test_sandbox") if err != nil { t.Fatalf("CallHook: %v", err) } t.Logf("sandbox test result: %v", result) // dofile is nil, so pcall will catch the error and return false // The hook returns the first value from Lua, which should be false if result == lua.LTrue { t.Error("dofile should not be available in sandbox") } } func TestLuaVM_State(t *testing.T) { dir := t.TempDir() pluginDir := filepath.Join(dir, "state") if err := os.MkdirAll(pluginDir, 0755); err != nil { t.Fatal(err) } // Test verstak.state API mainLua := ` function test_state() verstak.state.set("counter", 42) verstak.state.set("name", "test") local c = verstak.state.get("counter") local n = verstak.state.get("name") return c, n end ` if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil { t.Fatal(err) } p := &Plugin{ Meta: Meta{Name: "state"}, Dir: pluginDir, Active: true, } 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) } _, err = vm.CallHookWithResult("test_state") if err != nil { t.Fatalf("CallHook: %v", err) } // verstak.state.set/get should work (first return = counter = 42) // Currently returns "not implemented" stub error, but we verify the path t.Log("state test completed (stub — will fail if API wasn't callable)") } func TestPluginManager_InitRuntimes(t *testing.T) { dir := t.TempDir() pluginsDir := filepath.Join(dir, ".verstak", "plugins", "testp") if err := os.MkdirAll(pluginsDir, 0755); err != nil { t.Fatal(err) } // Write plugin.json pj := `{ "name": "testp", "version": "1.0.0", "hooks": { "on_install": "on_install", "on_init": "on_init" } }` if err := os.WriteFile(filepath.Join(pluginsDir, "plugin.json"), []byte(pj), 0644); err != nil { t.Fatal(err) } // Write main.lua if err := os.WriteFile(filepath.Join(pluginsDir, "main.lua"), []byte(`function on_init() print("manager init ok") end`), 0644); err != nil { t.Fatal(err) } mgr := NewManager(dir) mgr.Discover() mgr.Install("testp") mgr.Enable("testp") mgr.InitRuntimes() defer mgr.CloseRuntimes() if len(mgr.Plugins()) != 1 { t.Fatalf("expected 1 plugin, got %d", len(mgr.Plugins())) } p := mgr.Plugins()[0] if p.Meta.Name != "testp" { t.Fatalf("expected testp, got %s", p.Meta.Name) } if !p.Active { t.Fatal("plugin should be active") } if p.vm == nil { t.Fatal("plugin should have Lua VM") } // Call on_init through manager mgr.CallInitHooks() } // TestNodeAPI_CreateAndGet verifies that the verstak_node table is present and callable. func TestNodeAPI_CreateAndGet(t *testing.T) { dir := t.TempDir() pluginDir := filepath.Join(dir, "nodetest") if err := os.MkdirAll(pluginDir, 0755); err != nil { t.Fatal(err) } p := &Plugin{ Meta: Meta{Name: "nodetest"}, Dir: pluginDir, } vm, err := NewLuaVM(p) if err != nil { t.Fatalf("NewLuaVM: %v", err) } defer vm.Close() // Verify verstak_node global table exists nodeTable := vm.L.GetGlobal("verstak_node") if nodeTable == lua.LNil { t.Fatal("verstak_node is nil (expected table)") } if _, ok := nodeTable.(*lua.LTable); !ok { t.Fatalf("verstak_node type: %T (expected *lua.LTable)", nodeTable) } // Run a Lua snippet that calls verstak_node.get — should error since no services err = vm.L.DoString(` local ok, err = pcall(verstak_node.get, "fake-id") assert(not ok, "expected error from verstak_node.get with no services") `) if err != nil { t.Fatalf("Lua snippet: %v", err) } // Verify verstak.node table exists inside main verstak table mainTbl := vm.L.GetGlobal("verstak") if mainTbl == lua.LNil { t.Fatal("verstak global table missing") } verstakTbl, ok := mainTbl.(*lua.LTable) if !ok { t.Fatalf("verstak type: %T", mainTbl) } nodeSubTbl := verstakTbl.RawGetString("node") if nodeSubTbl == lua.LNil { t.Fatal("verstak.node is nil") } t.Log("node API present and callable without crash") } // TestCalendarPlugin_LoadAndRun runs the calendar plugin's Lua test suite // via the LuaVM to verify migration, CRUD, recurrence, and all other features. func TestCalendarPlugin_LoadAndRun(t *testing.T) { dir := t.TempDir() // Copy the calendar plugin from contrib/plugins/calendar to temp dir calendarSrc := filepath.Join("..", "..", "..", "contrib", "plugins", "calendar") pluginDir := filepath.Join(dir, "calendar") if err := copyDir(calendarSrc, pluginDir); err != nil { t.Fatalf("copy calendar plugin: %v", err) } dataDir := filepath.Join(dir, "calendar_data") if err := os.MkdirAll(dataDir, 0755); err != nil { t.Fatal(err) } p := &Plugin{ Meta: Meta{ Name: "calendar", Hooks: map[string]string{ "on_init": "on_init", "on_install": "on_install", "on_uninstall": "on_uninstall", }, }, Dir: pluginDir, DataDir: dataDir, Active: true, Installed: true, HasInstall: true, } vm, err := NewLuaVM(p) if err != nil { t.Fatalf("NewLuaVM: %v", err) } defer vm.Close() // Load main.lua if err := vm.LoadScript("main.lua"); err != nil { t.Fatalf("LoadScript(main.lua): %v", err) } // Run on_install — creates tables + default categories (skipped on re-install) if err := vm.CallHook("on_install"); err != nil { t.Fatalf("CallHook(on_install): %v", err) } // Run on_init — registers API + state if err := vm.CallHook("on_init"); err != nil { t.Fatalf("CallHook(on_init): %v", err) } // Run the Lua test suite if err := vm.LoadScript("test_calendar.lua"); err != nil { t.Fatalf("LoadScript(test_calendar.lua): %v", err) } t.Log("Calendar plugin: all Lua API tests passed") } // copyDir recursively copies src to dst (skips hidden files). func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } rel, err := filepath.Rel(src, path) if err != nil { return err } target := filepath.Join(dst, rel) if info.IsDir() { return os.MkdirAll(target, info.Mode()) } data, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(target, data, info.Mode()) }) }