package lua import ( "encoding/json" "os" "path/filepath" "strings" "testing" ) // newPluginVM builds a VM plus a plugin registry rooted at dir. func newPluginVM(t *testing.T) (*VM, *Plugins, string) { t.Helper() dir := filepath.Join(t.TempDir(), "adapters") vm := NewVM(dir) if err := vm.Start(); err != nil { t.Fatalf("vm start: %v", err) } t.Cleanup(vm.Stop) pdir := filepath.Join(t.TempDir(), "plugins") return vm, NewPlugins(vm, pdir), pdir } // loadPlugin writes one plugin to disk and loads it. func loadPlugin(t *testing.T, ps *Plugins, name, code string) error { t.Helper() if err := os.MkdirAll(ps.dir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(ps.dir, name+".lua"), []byte(code), 0644); err != nil { t.Fatal(err) } return ps.LoadSource(name, code) } // TestPluginManifestAndHooks: the two hook registration forms both work and the // manifest is read. func TestPluginManifestAndHooks(t *testing.T) { _, ps, _ := newPluginVM(t) code := ` local p = {} p.name = "demo" p.version = "1.2.3" p.description = "a demo plugin" p.author = "tester" p.hooks = { request_end = "on_end" } function p.on_end(payload) payload.seen = true payload.name_seen = "demo" return payload end return p ` if err := loadPlugin(t, ps, "demo", code); err != nil { t.Fatalf("load: %v", err) } list := ps.List() if len(list) != 1 { t.Fatalf("List() = %d plugins, want 1", len(list)) } if list[0]["name"] != "demo" || list[0]["version"] != "1.2.3" { t.Errorf("manifest not read: %+v", list[0]) } hooks := list[0]["hooks"].([]string) if len(hooks) != 1 || hooks[0] != string(StageRequestEnd) { t.Errorf("hooks = %v, want [request_end]", hooks) } out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"}) if out["seen"] != true || out["name_seen"] != "demo" { t.Errorf("hook did not mutate payload: %+v", out) } } // TestPluginAnonymousHookForm: `request_end = function() end` directly on the // table must register too, since a single-hook plugin should not need a name. func TestPluginAnonymousHookForm(t *testing.T) { _, ps, _ := newPluginVM(t) code := ` local p = { name = "anon" } p.request_end = function(payload) payload.hit = 1 return payload end return p ` if err := loadPlugin(t, ps, "anon", code); err != nil { t.Fatalf("load: %v", err) } out := ps.Fire(StageRequestEnd, map[string]interface{}{}) if out["hit"] != float64(1) { t.Errorf("anonymous hook did not fire: %+v", out) } } // TestPluginHookStagesFireInOrder: each stage reaches only its own hooks. func TestPluginHookStagesFireInOrder(t *testing.T) { _, ps, _ := newPluginVM(t) code := ` local p = { name = "stages" } p.hooks = { request_start = "s1", routed = "s2", request_end = "s3", } function p.s1(x) x.order = (x.order or "") .. "1" return x end function p.s2(x) x.order = (x.order or "") .. "2" return x end function p.s3(x) x.order = (x.order or "") .. "3" return x end return p ` if err := loadPlugin(t, ps, "stages", code); err != nil { t.Fatalf("load: %v", err) } payload := map[string]interface{}{} ps.Fire(StageRequestStart, payload) ps.Fire(StageRouted, payload) ps.Fire(StageRequestEnd, payload) if payload["order"] != "123" { t.Errorf("stage order = %v, want \"123\"", payload["order"]) } } // TestPluginErrorIsContained is the critical safety property: a throwing hook // must not propagate. Forwarding depends on it. func TestPluginErrorIsContained(t *testing.T) { _, ps, _ := newPluginVM(t) code := ` local p = { name = "boom" } p.hooks = { request_end = "kaboom" } function p.kaboom(payload) error("intentional plugin failure") end return p ` if err := loadPlugin(t, ps, "boom", code); err != nil { t.Fatalf("load: %v", err) } // Must not panic and must return the payload unchanged. out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"}) if out["model"] != "m" { t.Errorf("payload was altered by a failing plugin: %+v", out) } // And the failure must be visible, not silent. errs := ps.HookErrors() if errs["request_end"] == nil { t.Error("a failing plugin left no error record; it would be silently missing") } } // TestPluginFailingHookDoesNotBlockLaterPlugins: one bad plugin must not stop // the next one from running. func TestPluginFailingHookDoesNotBlockLaterPlugins(t *testing.T) { _, ps, _ := newPluginVM(t) bad := ` local p = { name = "bad" } p.hooks = { request_end = "f" } function p.f(x) error("boom") end return p ` good := ` local p = { name = "good" } p.hooks = { request_end = "f" } function p.f(x) x.good = true return x end return p ` _ = loadPlugin(t, ps, "bad", bad) if err := loadPlugin(t, ps, "good", good); err != nil { t.Fatalf("load good: %v", err) } out := ps.Fire(StageRequestEnd, map[string]interface{}{}) if out["good"] != true { t.Errorf("a good plugin was blocked by a failing one: %+v", out) } } // TestPluginSyntaxErrorIsIsolated: a plugin that will not compile is listed // with its error and is never called — it must not prevent LoadDir from loading // the rest. func TestPluginSyntaxErrorIsIsolated(t *testing.T) { _, ps, _ := newPluginVM(t) broken := "this is not lua(((" good := ` local p = { name = "ok" } p.hooks = { request_end = "f" } function p.f(x) x.ok = true return x end return p ` _ = loadPlugin(t, ps, "broken", broken) if err := loadPlugin(t, ps, "ok", good); err != nil { t.Fatalf("load ok: %v", err) } if err := ps.LoadDir(); err != nil { t.Fatalf("LoadDir: %v", err) } // The broken plugin must not be callable and must carry an error. for _, row := range ps.List() { if row["name"] == "broken" { if row["loaded"] == true { t.Error("a plugin with a syntax error reported itself as loaded") } if row["error"] == nil || row["error"] == "" { t.Error("a broken plugin carries no error message") } } } // The good plugin still works. out := ps.Fire(StageRequestEnd, map[string]interface{}{}) if out["ok"] != true { t.Errorf("good plugin stopped working: %+v", out) } } // TestPluginUIExtension: a plugin can contribute a page and elements. func TestPluginUIExtension(t *testing.T) { _, ps, _ := newPluginVM(t) code := ` local p = { name = "ui" } p.hooks = { request_end = "f" } function p.f(x) return x end p.ui = { page = { page_id = "billing", title = "Billing", icon = "💰", order = 50, mount = "