Files
Faza Iman Imron f373776d4c feat(plugins): add retained event pub/sub over IPC (#303)
* feat(ipc): add retained event subscriptions

* feat(plugins): expose namespaced event publishing

* feat(app): wire plugin events to IPC

* docs(plugins): document local event pubsub

* fix(ipc): clear request deadline for subscriptions

* fix(luaplugin): install event publisher before plugins load

* fix(luaplugin): clear retained events after publishers stop

* fix(ipc): keep event sequence gap-free when retention is rejected

* fix(luaplugin): break table cycles and cap depth in Lua value conversion

* docs(plugins): document conversion limits and subscription stream rules

* refactor(ipc): report a missing server with a sentinel error

* fix(luaplugin): fold event namespace into one topic segment

* docs(site): mention plugin event publishing and subscriptions

* fix(luaplugin): keep event namespaces unique across plugins

* fix(luaplugin): release event namespace by installed filename

* refactor(ipc): render the not-running message in the command layer

* docs(plugins): clarify namespace prefixes and JSON conversion limits
2026-08-18 17:19:28 +02:00

221 lines
4.9 KiB
Go

package luaplugin
import (
"encoding/json"
"testing"
lua "github.com/yuin/gopher-lua"
)
func TestJSONEncodeDecode(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerJSONAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`
local encoded = cliamp.json.encode({name = "test", count = 42})
local decoded = cliamp.json.decode(encoded)
_G.name = decoded.name
_G.count = decoded.count
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("name").String() != "test" {
t.Fatalf("name = %q", L.GetGlobal("name").String())
}
if float64(L.GetGlobal("count").(lua.LNumber)) != 42 {
t.Fatalf("count = %v", L.GetGlobal("count"))
}
}
func TestJSONDecodeInvalid(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerJSONAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`
local result, errmsg = cliamp.json.decode("not json")
_G.result = result
_G.errmsg = errmsg
`)
if err != nil {
t.Fatal(err)
}
if L.GetGlobal("result") != lua.LNil {
t.Fatalf("result = %v, want nil", L.GetGlobal("result"))
}
if L.GetGlobal("errmsg") == lua.LNil {
t.Fatal("errmsg should not be nil")
}
}
func TestJSONEncodeArray(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerJSONAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
err := L.DoString(`
_G.result = cliamp.json.encode({1, 2, 3})
`)
if err != nil {
t.Fatal(err)
}
if got := L.GetGlobal("result").String(); got != "[1,2,3]" {
t.Fatalf("encode([1,2,3]) = %q, want %q", got, "[1,2,3]")
}
}
func TestLuaToGoRoundtrip(t *testing.T) {
L := lua.NewState()
defer L.Close()
tests := []struct {
name string
val lua.LValue
want any
}{
{"nil", lua.LNil, nil},
{"bool", lua.LTrue, true},
{"number", lua.LNumber(3.14), 3.14},
{"string", lua.LString("hi"), "hi"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := luaToGo(tt.val)
switch w := tt.want.(type) {
case nil:
if got != nil {
t.Fatalf("got %v, want nil", got)
}
case bool:
if got != w {
t.Fatalf("got %v, want %v", got, w)
}
case float64:
if got != w {
t.Fatalf("got %v, want %v", got, w)
}
case string:
if got != w {
t.Fatalf("got %v, want %v", got, w)
}
}
})
}
}
func TestLuaToGoBreaksTableCycles(t *testing.T) {
L := lua.NewState()
defer L.Close()
self := L.NewTable()
self.RawSetString("name", lua.LString("self"))
self.RawSetString("self", self)
left := L.NewTable()
right := L.NewTable()
left.RawSetString("other", right)
right.RawSetString("other", left)
self.RawSetString("left", left)
// A cycle must not recurse until the Go stack overflows, which would kill
// cliamp instead of failing inside the plugin sandbox.
got, ok := luaToGo(self).(map[string]any)
if !ok {
t.Fatalf("luaToGo returned %T, want map", luaToGo(self))
}
if got["name"] != "self" {
t.Fatalf("name = %v, want self", got["name"])
}
if got["self"] != nil {
t.Fatalf("cyclic self reference = %v, want nil", got["self"])
}
if _, err := json.Marshal(got); err != nil {
t.Fatalf("marshal cyclic table: %v", err)
}
}
func TestLuaToGoKeepsRepeatedReferences(t *testing.T) {
L := lua.NewState()
defer L.Close()
shared := L.NewTable()
shared.RawSetString("id", lua.LString("shared"))
root := L.NewTable()
root.RawSetString("first", shared)
root.RawSetString("second", shared)
// The same table reached twice on different paths is not a cycle.
got, ok := luaToGo(root).(map[string]any)
if !ok {
t.Fatalf("luaToGo returned %T, want map", luaToGo(root))
}
for _, key := range []string{"first", "second"} {
child, ok := got[key].(map[string]any)
if !ok || child["id"] != "shared" {
t.Fatalf("%s = %#v, want shared table", key, got[key])
}
}
}
func TestLuaToGoLimitsNestingDepth(t *testing.T) {
L := lua.NewState()
defer L.Close()
root := L.NewTable()
deepest := root
for range maxLuaConvertDepth + 10 {
next := L.NewTable()
deepest.RawSetString("next", next)
deepest = next
}
current, ok := luaToGo(root).(map[string]any)
if !ok {
t.Fatalf("luaToGo returned %T, want map", luaToGo(root))
}
depth := 1
for {
next, isTable := current["next"].(map[string]any)
if !isTable {
break
}
current = next
depth++
}
if depth != maxLuaConvertDepth {
t.Fatalf("converted depth = %d, want %d", depth, maxLuaConvertDepth)
}
}
func TestJSONEncodeSurvivesCyclicTable(t *testing.T) {
L := lua.NewState()
defer L.Close()
cliamp := L.NewTable()
registerJSONAPI(L, cliamp)
L.SetGlobal("cliamp", cliamp)
if err := L.DoString(`
local t = {name = "loop"}
t.self = t
_G.result = cliamp.json.encode(t)
`); err != nil {
t.Fatal(err)
}
// The cyclic reference becomes null instead of crashing the process.
if got := L.GetGlobal("result").String(); got != `{"name":"loop","self":null}` {
t.Fatalf("encode(cyclic) = %q", got)
}
}