Files
wehub-resource-sync ead81af521
CI / go (push) Waiting to run
CI / build (darwin, macos-14) (push) Waiting to run
CI / build (windows, windows-2025) (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

655 lines
16 KiB
Go

package luaplugin
import (
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
lua "github.com/yuin/gopher-lua"
)
// newTestManager returns a Manager ready for testing (no disk I/O).
func newTestManager() *Manager {
return &Manager{
hooks: make(map[string][]*luaHook),
keyBinds: make(map[string][]*luaHook),
keyBindDescs: make(map[string]KeyBinding),
commands: make(map[string]map[string]*luaHook),
visMap: make(map[string]*luaVis),
timers: newTimerManager(),
execs: newExecManager(defaultAllowedBinaries),
}
}
// loadTestPlugin writes a Lua script to a temp file, loads it into the manager,
// and appends it to m.plugins if registration succeeded.
func loadTestPlugin(t *testing.T, m *Manager, name, code string) *Plugin {
return loadTestPluginWithConfig(t, m, name, code, nil)
}
func loadTestPluginWithConfig(t *testing.T, m *Manager, name, code string, cfg map[string]string) *Plugin {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name+".lua")
if err := os.WriteFile(path, []byte(code), 0o644); err != nil {
t.Fatal(err)
}
p, err := m.loadPlugin(path, name, cfg)
if err != nil {
t.Fatalf("loadPlugin(%s): %v", name, err)
}
if p != nil {
m.plugins = append(m.plugins, p)
}
return p
}
func loadTestPluginExpectError(t *testing.T, m *Manager, name, code string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name+".lua")
if err := os.WriteFile(path, []byte(code), 0o644); err != nil {
t.Fatal(err)
}
if _, err := m.loadPlugin(path, name, nil); err == nil {
t.Fatalf("expected error for %s", name)
}
}
func TestLoadPluginRegistersHookPlugin(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "test-hook", `
local p = plugin.register({
name = "test-hook",
type = "hook",
version = "1.0",
description = "a test plugin",
})
p:on("track.change", function(data) end)
`)
if p == nil {
t.Fatal("plugin is nil")
}
if p.Name != "test-hook" {
t.Fatalf("Name = %q, want %q", p.Name, "test-hook")
}
if p.Type != "hook" {
t.Fatalf("Type = %q, want %q", p.Type, "hook")
}
if p.Version != "1.0" {
t.Fatalf("Version = %q, want %q", p.Version, "1.0")
}
if len(m.hooks["track.change"]) != 1 {
t.Fatalf("hooks[track.change] = %d, want 1", len(m.hooks["track.change"]))
}
}
func TestLoadPluginWithoutRegisterReturnsNil(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "no-register", `-- does nothing`)
if p != nil {
t.Fatalf("expected nil plugin for script without register, got %+v", p)
}
}
func TestLoadPluginSyntaxError(t *testing.T) {
m := newTestManager()
dir := t.TempDir()
path := filepath.Join(dir, "bad.lua")
os.WriteFile(path, []byte(`this is not valid lua!!!`), 0o644)
_, err := m.loadPlugin(path, "bad", nil)
if err == nil {
t.Fatal("expected error for invalid Lua syntax")
}
}
func TestLoadPluginCleanupStopsPendingTimers(t *testing.T) {
cases := []struct {
name string
expectErr bool
code string
}{
{
name: "without register",
code: `
cliamp.timer.after(0.01, function()
cliamp.fs.write(%q, "fired")
end)
cliamp.sleep(0.05)
`,
},
{
name: "every",
code: `
cliamp.timer.every(0.01, function()
cliamp.fs.write(%q, "fired")
end)
cliamp.sleep(0.05)
`,
},
{
name: "on load error",
expectErr: true,
code: `
local p = plugin.register({name = "bad", type = "hook"})
cliamp.timer.after(0.01, function()
cliamp.fs.write(%q, "fired")
end)
cliamp.sleep(0.05)
error("boom")
`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := newTestManager()
path := filepath.Join(t.TempDir(), "fired")
code := fmt.Sprintf(tc.code, path)
if tc.expectErr {
loadTestPluginExpectError(t, m, "bad", code)
} else {
p := loadTestPlugin(t, m, "no-register-expired-timer", code)
if p != nil {
t.Fatalf("expected nil plugin for script without register, got %+v", p)
}
}
time.Sleep(50 * time.Millisecond)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("timer callback wrote %q after cleanup, err=%v", path, err)
}
})
}
}
func TestLoadPluginErrorRemovesHooks(t *testing.T) {
m := newTestManager()
loadTestPluginExpectError(t, m, "bad", `
local p = plugin.register({name = "bad", type = "hook"})
p:on("track.change", function() end)
error("boom")
`)
if len(m.hooks["track.change"]) != 0 {
t.Fatalf("hooks[track.change] = %d, want 0", len(m.hooks["track.change"]))
}
}
func TestLoadVisualizerErrorRemovesVisualizer(t *testing.T) {
m := newTestManager()
loadTestPluginExpectError(t, m, "bad", `
plugin.register({name = "bad", type = "visualizer"})
error("boom")
`)
if len(m.visPlugs) != 0 {
t.Fatalf("visualizer count = %d, want 0", len(m.visPlugs))
}
if _, ok := m.visMap["bad"]; ok {
t.Fatal("expected visualizer to be removed from visMap")
}
}
func TestPluginConfig(t *testing.T) {
m := newTestManager()
p := loadTestPluginWithConfig(t, m, "cfg", `
local p = plugin.register({name = "cfg", type = "hook"})
_G.got_url = p:config("url")
_G.got_missing = p:config("missing")
`, map[string]string{"url": "https://example.com"})
gotURL := p.L.GetGlobal("got_url")
if gotURL.String() != "https://example.com" {
t.Fatalf("config('url') = %q, want %q", gotURL.String(), "https://example.com")
}
gotMissing := p.L.GetGlobal("got_missing")
if gotMissing != lua.LNil {
t.Fatalf("config('missing') = %v, want nil", gotMissing)
}
}
func TestPluginPermissions(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "perm", `
plugin.register({
name = "perm",
type = "hook",
permissions = {"control"},
})
`)
if !p.perms["control"] {
t.Fatal("perms[control] = false, want true")
}
}
func TestEmitSync(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "sync-test", `
local p = plugin.register({name = "sync-test", type = "hook"})
_G.events = {}
p:on("test.event", function(data)
table.insert(_G.events, data.msg)
end)
`)
m.EmitSync("test.event", map[string]any{"msg": "hello"})
p := m.plugins[0]
events := p.L.GetGlobal("events").(*lua.LTable)
if events.Len() != 1 {
t.Fatalf("events length = %d, want 1", events.Len())
}
if events.RawGetInt(1).String() != "hello" {
t.Fatalf("events[1] = %q, want %q", events.RawGetInt(1).String(), "hello")
}
}
func TestEmitAsync(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "async-test", `
local p = plugin.register({name = "async-test", type = "hook"})
_G.called = false
p:on("test.event", function(data)
_G.called = true
end)
`)
m.Emit("test.event", nil)
// Wait for async handler to complete.
time.Sleep(100 * time.Millisecond)
p := m.plugins[0]
p.mu.Lock()
called := p.L.GetGlobal("called")
p.mu.Unlock()
if called != lua.LTrue {
t.Fatal("async event handler was not called")
}
}
func TestEmitMultipleHooks(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "multi", `
local p = plugin.register({name = "multi", type = "hook"})
_G.count = 0
p:on("test.event", function() _G.count = _G.count + 1 end)
p:on("test.event", function() _G.count = _G.count + 10 end)
`)
m.EmitSync("test.event", nil)
p := m.plugins[0]
count := p.L.GetGlobal("count")
if count.(lua.LNumber) != 11 {
t.Fatalf("count = %v, want 11", count)
}
}
func TestPluginCountAndHasHooks(t *testing.T) {
m := newTestManager()
if m.PluginCount() != 0 {
t.Fatalf("PluginCount() = %d, want 0", m.PluginCount())
}
if m.HasHooks() {
t.Fatal("HasHooks() = true, want false")
}
loadTestPlugin(t, m, "counter", `
local p = plugin.register({name = "counter", type = "hook"})
p:on("app.start", function() end)
`)
if m.PluginCount() != 1 {
t.Fatalf("PluginCount() = %d, want 1", m.PluginCount())
}
if !m.HasHooks() {
t.Fatal("HasHooks() = false, want true")
}
}
func TestClose(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "close-test", `
local p = plugin.register({name = "close-test", type = "hook"})
_G.quit = false
p:on("app.quit", function() _G.quit = true end)
`)
m.Close()
// After Close, the LState is shut down. We can't safely query it,
// but we verified it doesn't panic.
}
func TestManagerWithStateProvider(t *testing.T) {
m := newTestManager()
m.SetStateProvider(StateProvider{
PlayerState: func() string { return "playing" },
Volume: func() float64 { return -3.5 },
TrackTitle: func() string { return "Angel" },
TrackArtist: func() string { return "Massive Attack" },
})
p := loadTestPlugin(t, m, "state-test", `
local p = plugin.register({name = "state-test", type = "hook"})
_G.state = cliamp.player.state()
_G.vol = cliamp.player.volume()
_G.title = cliamp.track.title()
_G.artist = cliamp.track.artist()
`)
if p.L.GetGlobal("state").String() != "playing" {
t.Fatalf("state = %q, want %q", p.L.GetGlobal("state").String(), "playing")
}
if float64(p.L.GetGlobal("vol").(lua.LNumber)) != -3.5 {
t.Fatalf("vol = %v, want -3.5", p.L.GetGlobal("vol"))
}
if p.L.GetGlobal("title").String() != "Angel" {
t.Fatalf("title = %q", p.L.GetGlobal("title").String())
}
if p.L.GetGlobal("artist").String() != "Massive Attack" {
t.Fatalf("artist = %q", p.L.GetGlobal("artist").String())
}
}
func TestManagerWithControlProvider(t *testing.T) {
m := newTestManager()
var gotVol float64
m.SetControlProvider(ControlProvider{
SetVolume: func(db float64) { gotVol = db },
})
loadTestPlugin(t, m, "ctrl-test", `
plugin.register({
name = "ctrl-test",
type = "hook",
permissions = {"control"},
})
cliamp.player.set_volume(-10)
`)
if gotVol != -10 {
t.Fatalf("SetVolume called with %v, want -10", gotVol)
}
}
func TestControlClampsBounds(t *testing.T) {
m := newTestManager()
var gotVol float64
var gotSpeed float64
m.SetControlProvider(ControlProvider{
SetVolume: func(db float64) { gotVol = db },
SetSpeed: func(r float64) { gotSpeed = r },
})
loadTestPlugin(t, m, "clamp-test", `
plugin.register({
name = "clamp-test",
type = "hook",
permissions = {"control"},
})
cliamp.player.set_volume(100)
cliamp.player.set_speed(10)
`)
if gotVol != 6 {
t.Fatalf("set_volume(100) clamped to %v, want 6", gotVol)
}
if gotSpeed != 2.0 {
t.Fatalf("set_speed(10) clamped to %v, want 2.0", gotSpeed)
}
}
func TestControlWithoutPermissionIsNoop(t *testing.T) {
m := newTestManager()
m.logger = newPluginLogger(filepath.Join(t.TempDir(), "test.log"))
t.Cleanup(m.Close)
called := false
m.SetControlProvider(ControlProvider{
SetVolume: func(db float64) { called = true },
})
loadTestPlugin(t, m, "no-perm", `
plugin.register({name = "no-perm", type = "hook"})
cliamp.player.set_volume(-10)
`)
if called {
t.Fatal("SetVolume was called without control permission")
}
}
func TestStateProviderDefaultsWhenNil(t *testing.T) {
m := newTestManager()
// No state provider set — all callbacks are nil.
p := loadTestPlugin(t, m, "defaults", `
local p = plugin.register({name = "defaults", type = "hook"})
_G.state = cliamp.player.state()
_G.vol = cliamp.player.volume()
_G.speed = cliamp.player.speed()
_G.pos = cliamp.player.position()
_G.title = cliamp.track.title()
`)
if p.L.GetGlobal("state").String() != "stopped" {
t.Fatalf("default state = %q, want 'stopped'", p.L.GetGlobal("state").String())
}
if float64(p.L.GetGlobal("vol").(lua.LNumber)) != 0 {
t.Fatalf("default vol = %v, want 0", p.L.GetGlobal("vol"))
}
if float64(p.L.GetGlobal("speed").(lua.LNumber)) != 1 {
t.Fatalf("default speed = %v, want 1", p.L.GetGlobal("speed"))
}
if p.L.GetGlobal("title").String() != "" {
t.Fatalf("default title = %q, want empty", p.L.GetGlobal("title").String())
}
}
func TestTimerAfter(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "timer-test", `
local p = plugin.register({name = "timer-test", type = "hook"})
_G.fired = false
cliamp.timer.after(0.05, function()
_G.fired = true
end)
`)
time.Sleep(150 * time.Millisecond)
p.mu.Lock()
fired := p.L.GetGlobal("fired")
p.mu.Unlock()
if fired != lua.LTrue {
t.Fatal("timer.after callback was not fired")
}
}
func TestTimerCancel(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "cancel-test", `
local p = plugin.register({name = "cancel-test", type = "hook"})
_G.fired = false
local id = cliamp.timer.after(0.2, function()
_G.fired = true
end)
cliamp.timer.cancel(id)
`)
time.Sleep(300 * time.Millisecond)
p.mu.Lock()
fired := p.L.GetGlobal("fired")
p.mu.Unlock()
if fired == lua.LTrue {
t.Fatal("timer.after callback fired after cancel")
}
}
func TestVisualizerPlugin(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "test-vis", `
local v = plugin.register({name = "test-vis", type = "visualizer"})
_G.init_called = false
_G.destroy_called = false
v.init = function(self, rows, cols)
_G.init_called = true
_G.init_rows = rows
end
v.render = function(self, bands, frame, rows, cols)
return "frame-" .. tostring(frame)
end
v.destroy = function(self)
_G.destroy_called = true
end
`)
m.finalizeVisualizers()
names := m.Visualizers()
if len(names) != 1 || names[0] != "test-vis" {
t.Fatalf("Visualizers() = %v, want [test-vis]", names)
}
m.InitVis("test-vis", 8, 40)
vis := m.visMap["test-vis"]
vis.plugin.mu.Lock()
initCalled := vis.plugin.L.GetGlobal("init_called")
vis.plugin.mu.Unlock()
if initCalled != lua.LTrue {
t.Fatal("init callback was not called")
}
got := m.RenderVis("test-vis", [10]float64{}, 8, 40, 42)
if got != "frame-42" {
t.Fatalf("RenderVis() = %q, want %q", got, "frame-42")
}
m.DestroyVis("test-vis")
vis.plugin.mu.Lock()
destroyCalled := vis.plugin.L.GetGlobal("destroy_called")
vis.plugin.mu.Unlock()
if destroyCalled != lua.LTrue {
t.Fatal("destroy callback was not called")
}
}
func TestRenderVisReusesLastOnError(t *testing.T) {
m := newTestManager()
loadTestPlugin(t, m, "err-vis", `
local v = plugin.register({name = "err-vis", type = "visualizer"})
local calls = 0
v.render = function(self, bands, frame, rows, cols)
calls = calls + 1
if calls == 2 then
error("boom")
end
return "ok-" .. tostring(calls)
end
`)
m.finalizeVisualizers()
got1 := m.RenderVis("err-vis", [10]float64{}, 8, 40, 1)
if got1 != "ok-1" {
t.Fatalf("frame 1 = %q, want %q", got1, "ok-1")
}
// Frame 2 errors — should reuse last frame.
got2 := m.RenderVis("err-vis", [10]float64{}, 8, 40, 2)
if got2 != "ok-1" {
t.Fatalf("frame 2 (error) = %q, want %q (reused)", got2, "ok-1")
}
}
func TestDataToTableConversion(t *testing.T) {
L := lua.NewState()
defer L.Close()
tbl := dataToTable(L, map[string]any{
"str": "hello",
"num": 42,
"float": 3.14,
"bool": true,
"nil": nil,
})
if tbl.RawGetString("str").String() != "hello" {
t.Fatalf("str = %v", tbl.RawGetString("str"))
}
if float64(tbl.RawGetString("num").(lua.LNumber)) != 42 {
t.Fatalf("num = %v", tbl.RawGetString("num"))
}
if float64(tbl.RawGetString("float").(lua.LNumber)) != 3.14 {
t.Fatalf("float = %v", tbl.RawGetString("float"))
}
if tbl.RawGetString("bool") != lua.LTrue {
t.Fatalf("bool = %v", tbl.RawGetString("bool"))
}
if tbl.RawGetString("nil") != lua.LNil {
t.Fatalf("nil = %v", tbl.RawGetString("nil"))
}
}
func TestDataToTableNested(t *testing.T) {
L := lua.NewState()
defer L.Close()
tbl := dataToTable(L, map[string]any{
"nested": map[string]any{"key": "value"},
"floats": []float64{1.0, 2.0, 3.0},
})
nested := tbl.RawGetString("nested").(*lua.LTable)
if nested.RawGetString("key").String() != "value" {
t.Fatalf("nested.key = %v", nested.RawGetString("key"))
}
floats := tbl.RawGetString("floats").(*lua.LTable)
if float64(floats.RawGetInt(1).(lua.LNumber)) != 1.0 {
t.Fatalf("floats[1] = %v", floats.RawGetInt(1))
}
if floats.Len() != 3 {
t.Fatalf("floats length = %d, want 3", floats.Len())
}
}
func TestConcurrentEmitSafety(t *testing.T) {
m := newTestManager()
p := loadTestPlugin(t, m, "concurrent", `
local p = plugin.register({name = "concurrent", type = "hook"})
_G.count = 0
p:on("inc", function() _G.count = _G.count + 1 end)
`)
var wg sync.WaitGroup
for range 20 {
wg.Go(func() {
m.Emit("inc", nil)
})
}
wg.Wait()
time.Sleep(200 * time.Millisecond)
p.mu.Lock()
count := float64(p.L.GetGlobal("count").(lua.LNumber))
p.mu.Unlock()
if count != 20 {
t.Fatalf("count after 20 concurrent emits = %v, want 20", count)
}
}