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 = "
hi
", }, elements = { { target = "status", anchor = "top", mount = "
cost
" }, }, } return p ` if err := loadPlugin(t, ps, "ui", code); err != nil { t.Fatalf("load: %v", err) } ui := ps.UI() if ui.Page == nil { t.Fatal("no page contributed") } if ui.Page.PageID != "billing" || ui.Page.Title != "Billing" { t.Errorf("page = %+v", ui.Page) } if !strings.Contains(ui.Page.Mount, "console.log") { t.Error("mount lost its script content") } if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" { t.Errorf("elements = %+v", ui.Elements) } } // TestPluginHookReturnsNilIsNoOpinion: a hook returning nothing must leave the // payload untouched (plugins should not be forced to echo it back). func TestPluginHookReturnsNilIsNoOpinion(t *testing.T) { _, ps, _ := newPluginVM(t) code := ` local p = { name = "silent" } p.hooks = { request_end = "f" } function p.f(payload) -- records nothing, returns nothing return nil end return p ` if err := loadPlugin(t, ps, "silent", code); err != nil { t.Fatalf("load: %v", err) } out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "ok": true}) if out["model"] != "m" || out["ok"] != true { t.Errorf("a no-op hook disturbed the payload: %+v", out) } } // TestPluginUIJSONShape is a wire-format guard: the kernel sends this to the // browser, so the shape is a contract with the WebUI. func TestPluginUIJSONShape(t *testing.T) { _, ps, _ := newPluginVM(t) code := ` local p = { name = "shape" } p.hooks = { request_end = "f" } function p.f(x) return x end p.ui = { elements = { { target = "keys", mount = "k" } } } return p ` if err := loadPlugin(t, ps, "shape", code); err != nil { t.Fatalf("load: %v", err) } b, err := json.Marshal(ps.UI()) if err != nil { t.Fatalf("marshal UI: %v", err) } var view struct { Elements []struct { Target string `json:"target"` Mount string `json:"mount"` } `json:"elements"` } if err := json.Unmarshal(b, &view); err != nil { t.Fatalf("unmarshal: %v", err) } if len(view.Elements) != 1 || view.Elements[0].Target != "keys" { t.Errorf("UI JSON shape = %+v", view.Elements) } } // TestPluginFireWithNoPluginsIsNoop: an empty registry must not allocate or fail. func TestPluginFireWithNoPluginsIsNoop(t *testing.T) { _, ps, _ := newPluginVM(t) in := map[string]interface{}{"a": 1} out := ps.Fire(StageRequestEnd, in) if out["a"] != 1 || ps.Count() != 0 { t.Errorf("empty registry misbehaved: %+v", out) } }