package plugin // 本文件锁定「按 server 独立启停」的三条硬要求(切片 M20): // ① 热切换:停用后工具**真的**从注册表消失(模型看不见它们),启用后回来; // ② 持久化:`enabled:false` 落进 plugins.json,重启(Restore)后仍是停用 —— 而且 // **一次都不去连**(不是「连了一下再判定停用」); // ③ 失败如实:启用失败时不假装开着,落盘的 enabled 保持 false,快照给 lastError。 import ( "context" "errors" "path/filepath" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // newEnabledManager 构造一台连到假 MCP server 的已安装插件,返回管理器与它的注册表。 func newEnabledManager(t *testing.T, path string, endpoint string, tools ...string) (*Manager, *registry.Registry) { t.Helper() reg := registry.New() mgr := NewManager(reg, path, nil) t.Cleanup(func() { mgr.Close(context.Background()) }) if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{ Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: endpoint, }); err != nil { t.Fatalf("Install: %v", err) } return mgr, reg } // TestDisableUnregistersToolsAndPersists 锁定 ①+② 的一半:停用即摘工具、写 enabled:false。 func TestDisableUnregistersToolsAndPersists(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "plugins.json") srv := newFakeMCP(t, "", "add", "mul") mgr, reg := newEnabledManager(t, path, srv.URL) if names := toolNames(reg); !names["add"] || !names["mul"] { t.Fatalf("安装后工具 = %v,期望 add+mul", names) } if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil { t.Fatalf("SetEnabled(false): %v", err) } // 工具必须真的不在注册表里:模型的面就是这样来的。 if names := toolNames(reg); len(names) != 0 { t.Fatalf("停用后工具仍可被模型看到 = %v", names) } if _, err := reg.Get("mcp:calc"); err == nil { t.Fatal("停用后 mcp:calc 能力仍在") } cfgs, err := runtimeconfig.LoadPlugins(path) if err != nil { t.Fatalf("LoadPlugins: %v", err) } if len(cfgs) != 1 || cfgs[0].IsEnabled() { t.Fatalf("落盘配置 = %+v,期望仅一条且已停用", cfgs) } if cfgs[0].Enabled == nil { t.Fatal("停用必须写显式的 enabled=false(缺席=启用,无法表达停用)") } st := statusOfOne(t, mgr, "calc") if st.State != ServerStateDisabled || st.Connected == nil || *st.Connected { t.Fatalf("停用后状态 = %+v,期望 disabled/connected=false", st) } if st.ToolCount != nil { t.Fatalf("停用的 server 不该报工具数(不知道它有什么),得到 %d", *st.ToolCount) } } // TestEnableBringsToolsBack 锁定 ①的另一半:启用重新连接并重新注册。 func TestEnableBringsToolsBack(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "plugins.json") srv := newFakeMCP(t, "", "add", "mul") mgr, reg := newEnabledManager(t, path, srv.URL) ctx := context.Background() if err := mgr.SetEnabled(ctx, "calc", false); err != nil { t.Fatalf("SetEnabled(false): %v", err) } if err := mgr.SetEnabled(ctx, "calc", true); err != nil { t.Fatalf("SetEnabled(true): %v", err) } if names := toolNames(reg); !names["add"] || !names["mul"] { t.Fatalf("启用后工具 = %v,期望 add+mul 回来", names) } cfgs, err := runtimeconfig.LoadPlugins(path) if err != nil { t.Fatalf("LoadPlugins: %v", err) } if len(cfgs) != 1 || !cfgs[0].IsEnabled() { t.Fatalf("启用后落盘 = %+v,期望 enabled=true", cfgs) } st := statusOfOne(t, mgr, "calc") if st.State != ServerStateConnected || st.ToolCount == nil || *st.ToolCount != 2 { t.Fatalf("启用后状态 = %+v,期望 connected 且 2 把工具", st) } if len(st.Tools) != 2 { t.Fatalf("工具明细 = %+v,期望 2 条", st.Tools) } } // TestRestoreHonoursDisabledFlag 锁定 ②:重启后停用仍然是停用,而且**一次都不连**。 func TestRestoreHonoursDisabledFlag(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "plugins.json") srv := newFakeMCP(t, "", "add") mgr, _ := newEnabledManager(t, path, srv.URL) if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil { t.Fatalf("SetEnabled(false): %v", err) } mgr.Close(context.Background()) before := srv.Config.Handler.(*fakeMCP).hitCount() reg2 := registry.New() mgr2 := NewManager(reg2, path, nil) defer mgr2.Close(context.Background()) mgr2.Restore(context.Background()) if names := toolNames(reg2); len(names) != 0 { t.Fatalf("重启后停用的 server 仍注册了工具 = %v", names) } if after := srv.Config.Handler.(*fakeMCP).hitCount(); after != before { t.Fatalf("重启期对停用的 server 发起了连接:hits %d -> %d", before, after) } st := statusOfOne(t, mgr2, "calc") if st.State != ServerStateDisabled || st.Enabled { t.Fatalf("重启后状态 = %+v,期望仍是 disabled", st) } } // TestInstallDisabledDoesNotConnect 锁定 Install 也尊重 enabled:装上但停用 = 落盘且不连。 func TestInstallDisabledDoesNotConnect(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "plugins.json") srv := newFakeMCP(t, "", "add") reg := registry.New() mgr := NewManager(reg, path, nil) defer mgr.Close(context.Background()) got, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{ Name: "calc", Endpoint: srv.URL, Enabled: enabledFlag(false), }) if err != nil { t.Fatalf("Install(disabled): %v", err) } if got.Live { t.Fatalf("停用安装不该有活 server:%+v", got) } if hits := srv.Config.Handler.(*fakeMCP).hitCount(); hits != 0 { t.Fatalf("停用安装发起了 %d 次连接", hits) } if names := toolNames(reg); len(names) != 0 { t.Fatalf("停用安装注册了工具 = %v", names) } cfgs, err := runtimeconfig.LoadPlugins(path) if err != nil || len(cfgs) != 1 || cfgs[0].IsEnabled() { t.Fatalf("落盘 = %+v(err=%v),期望一条停用配置", cfgs, err) } } // TestEnableFailureKeepsDisabled 锁定 ③:启用失败 ⇒ 返回错误 + 落盘仍是停用 + 快照给 lastError。 func TestEnableFailureKeepsDisabled(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "plugins.json") srv := newFakeMCP(t, "", "add") mgr, _ := newEnabledManager(t, path, srv.URL) ctx := context.Background() if err := mgr.SetEnabled(ctx, "calc", false); err != nil { t.Fatalf("SetEnabled(false): %v", err) } // 让 server 连不上:关掉进程内的假 server。 srv.Close() if err := mgr.SetEnabled(ctx, "calc", true); err == nil { t.Fatal("启用一台连不上的 server 应当报错") } cfgs, err := runtimeconfig.LoadPlugins(path) if err != nil || len(cfgs) != 1 { t.Fatalf("落盘 = %+v(err=%v)", cfgs, err) } if cfgs[0].IsEnabled() { t.Fatalf("启用失败却把 enabled 写成了 true:%+v", cfgs[0]) } // 状态仍是 disabled(启用没成功,它就还是停用态),而 lastError 说清了这次失败 —— // 「有意停用」与「想启用但失败」由这两列分别表达,不互相冒充。 st := statusOfOne(t, mgr, "calc") if st.State != ServerStateDisabled || st.Enabled { t.Fatalf("启用失败后状态 = %q/enabled=%v,期望 disabled/false", st.State, st.Enabled) } if st.Connected == nil || *st.Connected { t.Fatalf("启用失败后 connected = %v,期望 false(确认没连)", st.Connected) } if st.LastError == "" { t.Fatal("启用失败后 lastError 为空:面板说不出为什么挂了") } if st.LastErrorAt == "" { t.Fatal("启用失败后 lastErrorAt 为空") } } // TestReconnectRejectsDisabled 锁定:重连不替用户启用(那会让「它为什么忽然活了」变成谜)。 func TestReconnectRejectsDisabled(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "plugins.json") srv := newFakeMCP(t, "", "add") mgr, _ := newEnabledManager(t, path, srv.URL) ctx := context.Background() if err := mgr.SetEnabled(ctx, "calc", false); err != nil { t.Fatalf("SetEnabled(false): %v", err) } if _, err := mgr.Reconnect(ctx, "calc"); !errors.Is(err, ErrPluginDisabled) { t.Fatalf("对停用的 server 重连 = %v,期望 ErrPluginDisabled", err) } if _, err := mgr.Reconnect(ctx, "ghost"); !errors.Is(err, ErrPluginNotFound) { t.Fatalf("重连不存在的 server = %v,期望 ErrPluginNotFound", err) } } // enabledFlag 是 `&false` 的简写(PluginConfig.Enabled 是三态指针)。 func enabledFlag(v bool) *bool { return &v }