Files
Cyrene_v2/internal/capability/plugin/panels_lifecycle_test.go
T
AskaEth 2d21aa553e feat(plugins): 插件管理器接线 —— 面板接入点快照 + 动作分发(PLG-2)
- GET /api/v1/admin/panels:按调用者角色过滤后的接入点快照(PLG-1 的 catalog 挂进插件管理器)
- POST /api/v1/admin/plugins/{id}/actions/{action}:**只接受该插件声明过的动作名**
  (未声明 ⇒ 404);写动作 writeAuth + 落审计;插件只给动作名不给 URL ⇒ 不给插件开任意请求面
- 生命周期:install/uninstall/enable/disable 变化后**重算面板集合**并经进程内 bus 推 panels_changed
  (前端可热更新;不新开端口)

契约 v0.78 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
2026-10-06 09:03:57 +08:00

142 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"context"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定 PLG-2 的**接线**:面板目录跟着插件管理器的安装/卸载/启停一起重算,
// 并且每次重算推一条带原因的变更通知(§5.2.1 生命周期一栏)。
// newPanelManager 构造一个已接线面板中枢的管理器(plugins.json 落在临时目录)。
func newPanelManager(t *testing.T) (*Manager, *Panels, *changeRecorder) {
t.Helper()
mgr := NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), nil)
hub, rec := newTestHub(t)
mgr.AttachPanels(hub)
if mgr.Panels() != hub {
t.Fatalf("Panels() 未回接线的中枢")
}
return mgr, hub, rec
}
// installFake 安装一个指向假 MCP server 的插件。
func installFake(t *testing.T, mgr *Manager, name, endpoint string) {
t.Helper()
_, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: name, Kind: runtimeconfig.PluginKindMCP,
Transport: runtimeconfig.TransportHTTP, Endpoint: endpoint,
})
if err != nil {
t.Fatalf("Install(%s): %v", name, err)
}
}
// TestManagerPanelLifecycleInstallUninstall 锁定:安装 ⇒ 声明可见 + 推 install;
// 卸载 ⇒ 声明与动作一起消失 + 推 uninstall。
func TestManagerPanelLifecycleInstallUninstall(t *testing.T) {
t.Parallel()
mcp := newFakeMCP(t, "", "add")
mgr, hub, rec := newPanelManager(t)
defer mgr.Close(context.Background())
// 声明在安装之前就登记(模拟未来的 manifest 读取:先有声明,装成之后生效)。
if _, err := hub.Declare("calc", []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare: %v", err)
}
installFake(t, mgr, "calc", mcp.URL)
if got := mustIDs(t, hub); len(got) != 1 {
t.Fatalf("安装后应当看到面板,实际 %v", got)
}
if _, ok := hub.Action("calc", "plugin.saveProvider"); !ok {
t.Fatalf("安装后动作应当可分发")
}
if removed, err := mgr.Uninstall(context.Background(), "calc"); err != nil || !removed {
t.Fatalf("Uninstall = (%v,%v),期望 (true,nil)", removed, err)
}
if got := mustIDs(t, hub); len(got) != 0 {
t.Fatalf("卸载后不该再有面板,实际 %v", got)
}
if _, ok := hub.Action("calc", "plugin.saveProvider"); ok {
t.Fatalf("卸载后动作不得仍被声明")
}
reasons := changeReasons(rec.all())
if len(reasons) != 3 || reasons[0] != PanelChangeDeclared ||
reasons[1] != PanelChangeInstalled || reasons[2] != PanelChangeUninstalled {
t.Fatalf("变更原因 = %v,期望 [declare install uninstall]", reasons)
}
}
// TestManagerPanelLifecycleDisableEnable 锁定 §5.2.1 的启停一栏:停用摘掉入口
// (且动作不再分发),启用让它回来 —— 两条路各推一条变更。
func TestManagerPanelLifecycleDisableEnable(t *testing.T) {
t.Parallel()
mcp := newFakeMCP(t, "", "add")
mgr, hub, rec := newPanelManager(t)
caps := mgr.reg
defer mgr.Close(context.Background())
if _, err := hub.Declare("calc", []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare: %v", err)
}
installFake(t, mgr, "calc", mcp.URL)
before := len(rec.all())
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if got := mustIDs(t, hub); len(got) != 0 {
t.Fatalf("停用后入口应当不出现,实际 %v", got)
}
if _, err := caps.Get("mcp:calc"); err == nil {
t.Fatalf("停用后该插件的 MCP 能力应当已下线")
}
if err := mgr.SetEnabled(context.Background(), "calc", true); err != nil {
t.Fatalf("SetEnabled(true): %v", err)
}
if got := mustIDs(t, hub); len(got) != 1 {
t.Fatalf("重新启用后入口应当回来,实际 %v", got)
}
reasons := changeReasons(rec.all())[before:]
if len(reasons) != 2 || reasons[0] != PanelChangeDisabled || reasons[1] != PanelChangeEnabled {
t.Fatalf("启停变更原因 = %v,期望 [disable enable]", reasons)
}
// 未安装的名字必须显式报「找不到」,绝不静默成功。
if err := mgr.SetEnabled(context.Background(), "ghost", false); err == nil {
t.Fatalf("停用一个不存在的插件应当报错")
}
}
// TestManagerPanelsUnwiredIsNil 锁定未接线时的诚实语义:Panels() 回 nil,
// 管理器对面板的一切调用都是安全空操作(既有行为逐字不变)。
func TestManagerPanelsUnwiredIsNil(t *testing.T) {
t.Parallel()
mcp := newFakeMCP(t, "", "add")
mgr := NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), nil)
defer mgr.Close(context.Background())
if mgr.Panels() != nil {
t.Fatalf("未接线时 Panels() 应当回 nil")
}
installFake(t, mgr, "calc", mcp.URL)
if _, err := mgr.Uninstall(context.Background(), "calc"); err != nil {
t.Fatalf("未接线时卸载不应受影响: %v", err)
}
}
// changeReasons 抽取变更原因序列(顺序敏感)。
func changeReasons(changes []PanelChange) []string {
out := make([]string, 0, len(changes))
for _, c := range changes {
out = append(out, c.Reason)
}
return out
}