mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 07:02:29 +00:00
桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的
插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一
能用的三个操作。
补齐(复用后端已有契约,未新增端点):
- POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验
名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默
什么都没存。
- GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。
- DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。
- 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个
保存后被丢弃的文本框比直接说明更糟。
- 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。
后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List
里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的
合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种
写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。
变异去掉赋值后两条判据都失败。
CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、
热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误
被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
448 lines
13 KiB
Go
448 lines
13 KiB
Go
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 = "<div id=billing>hi</div><script>console.log('m')</script>",
|
|
},
|
|
elements = {
|
|
{ target = "status", anchor = "top", mount = "<div>cost</div>" },
|
|
},
|
|
}
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "ui", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
// Every contributed page — including the single `page` field — is folded
|
|
// into one merged list. Reading ui.Page here would silently pass on a
|
|
// payload whose pages were all dropped.
|
|
ui := ps.UI()
|
|
if len(ui.Pages) != 1 {
|
|
t.Fatalf("expected 1 merged page, got %d", len(ui.Pages))
|
|
}
|
|
pg := ui.Pages[0]
|
|
if pg.PageID != "billing" || pg.Title != "Billing" {
|
|
t.Errorf("page = %+v", pg)
|
|
}
|
|
if !strings.Contains(pg.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 = "<b>k</b>" } } }
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestChainStepIsARealStage: chain_step is documented as a distinct stage that
|
|
// fires once per step of an AUTO walk. If it were only a field on `routed`, a
|
|
// plugin author following the docs would silently get one event instead of the
|
|
// whole walk.
|
|
func TestChainStepIsARealStage(t *testing.T) {
|
|
found := false
|
|
for _, s := range AllStages {
|
|
if s == StageChainStep {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("StageChainStep is not in AllStages, so the dispatcher never registers it")
|
|
}
|
|
// Ordering: it must sit between request_start and routed, which is what
|
|
// docs/plugins.md promises.
|
|
var iStart, iStep, iRouted = -1, -1, -1
|
|
for i, s := range AllStages {
|
|
switch s {
|
|
case StageRequestStart:
|
|
iStart = i
|
|
case StageChainStep:
|
|
iStep = i
|
|
case StageRouted:
|
|
iRouted = i
|
|
}
|
|
}
|
|
if !(iStart < iStep && iStep < iRouted) {
|
|
t.Errorf("stage order = %v, want request_start < chain_step < routed", AllStages)
|
|
}
|
|
_, ps, _ := newPluginVM(t)
|
|
code := `
|
|
local p = { name = "stepper" }
|
|
p.hooks = { chain_step = "s" }
|
|
function p.s(payload)
|
|
payload.kinds = (payload.kinds or "")
|
|
return nil
|
|
end
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "stepper", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
// It must actually dispatch.
|
|
seen := false
|
|
for _, row := range ps.List() {
|
|
if row["name"] == "stepper" {
|
|
hooks := row["hooks"].([]string)
|
|
for _, h := range hooks {
|
|
if h == string(StageChainStep) {
|
|
seen = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !seen {
|
|
t.Error("a plugin registered for chain_step is not reported as such")
|
|
}
|
|
}
|
|
|
|
// TestOnDiskReportsPages pins that the disk listing carries the plugin's UI
|
|
// page ids. The desktop plugin panel reads on_disk (not List) and shows the
|
|
// pages so an operator can see at a glance that a plugin renders something;
|
|
// without this field the GUI reads p.pages === undefined and the line simply
|
|
// never appears, which is the kind of omission nobody reports as a bug.
|
|
func TestOnDiskReportsPages(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
|
|
code := `
|
|
local p = { name = "paged", version = "1.0.0" }
|
|
p.ui = { pages = {} }
|
|
table.insert(p.ui.pages, { page_id = "alpha", title = "Alpha", mount = "<b>a</b>" })
|
|
table.insert(p.ui.pages, { page_id = "beta", title = "Beta", mount = "<b>b</b>" })
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "paged", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
var found *DiskEntry
|
|
for _, de := range ps.OnDisk() {
|
|
if de.Name == "paged" {
|
|
d := de
|
|
found = &d
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("paged plugin missing from OnDisk")
|
|
}
|
|
if len(found.Pages) != 2 {
|
|
t.Fatalf("Pages = %v, want [alpha beta]", found.Pages)
|
|
}
|
|
if found.Pages[0] != "alpha" || found.Pages[1] != "beta" {
|
|
t.Fatalf("Pages = %v, want [alpha beta] in declaration order", found.Pages)
|
|
}
|
|
|
|
// The field must survive JSON too — the desktop panel reads the HTTP body,
|
|
// not the Go struct.
|
|
b, err := json.Marshal(found)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(b), `"pages":["alpha","beta"]`) {
|
|
t.Fatalf("json omits pages: %s", b)
|
|
}
|
|
}
|
|
|
|
// The single-page form (`ui.page`) must also surface, since a plugin using it
|
|
// is just as rendered as one using `ui.pages`.
|
|
func TestOnDiskReportsSinglePage(t *testing.T) {
|
|
_, ps, _ := newPluginVM(t)
|
|
code := `
|
|
local p = { name = "single", version = "1.0.0" }
|
|
p.ui = { page = { page_id = "solo", title = "Solo", mount = "<b>x</b>" } }
|
|
return p
|
|
`
|
|
if err := loadPlugin(t, ps, "single", code); err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
for _, de := range ps.OnDisk() {
|
|
if de.Name == "single" {
|
|
if len(de.Pages) != 1 || de.Pages[0] != "solo" {
|
|
t.Fatalf("Pages = %v, want [solo]", de.Pages)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("single plugin missing from OnDisk")
|
|
}
|