package main import ( "context" "path/filepath" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // 本文件锁定组合根的面板接线(切片 PLG-2):wirePanelHub 必须把插件管理器的面板 // 中枢接到**既有进程内 bus** 的 `ws.panels_changed` 上 —— 前端热更新靠这条帧, // 且不新开任何端口(单端口红线)。 // // 为什么在组合根测:帧的主题名与载荷形状是跨片契约(前端片按它消费), // 只有对 wirePanelHub 取证才能证明「组合根真的发的是这一条」。 // TestWirePanelHubPublishesPanelsChanged 断言生命周期变化真的经 bus 推出 // `ws.panels_changed`,且载荷带 reason / pluginId(不含任何面板内容)。 func TestWirePanelHubPublishesPanelsChanged(t *testing.T) { b := bus.New() defer b.Close() mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog()) defer mgr.Close(context.Background()) wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()}) hub := mgr.Panels() if hub == nil { t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上") } events := make(chan bus.Event, 8) if _, err := b.Subscribe(context.Background(), "ws.panels_changed", func(_ context.Context, e bus.Event) error { events <- e return nil }); err != nil { t.Fatalf("subscribe: %v", err) } enabled := true panels := []sdk.Panel{{ ID: "providers", Title: "搜索提供商", Order: 1, When: &sdk.When{Enabled: &enabled}, Sections: []sdk.Section{{ Type: sdk.SectionStatus, ID: "chain", Title: "降级链", Fields: []sdk.Field{{Key: "active", Type: sdk.FieldText}}, }}, }} if _, err := hub.Declare("web-search", panels); err != nil { t.Fatalf("Declare: %v", err) } hub.SetEnabled("web-search", false) for _, want := range []string{"declare", "disable"} { select { case e := <-events: if e.Subject != "ws.panels_changed" { t.Fatalf("subject = %q", e.Subject) } payload, ok := e.Payload.(map[string]any) if !ok { t.Fatalf("载荷类型 = %T,期望 map[string]any", e.Payload) } if payload["reason"] != want { t.Fatalf("reason = %v,期望 %q", payload["reason"], want) } if payload["pluginId"] != "web-search" { t.Fatalf("pluginId = %v,期望 web-search", payload["pluginId"]) } // 帧刻意不带内容:前端按自己的角色重拉快照,避免在这里再实现一遍角色过滤。 if _, leaked := payload["panels"]; leaked { t.Fatalf("panels_changed 帧不得携带面板内容:%v", payload) } case <-time.After(2 * time.Second): t.Fatalf("未收到 %q 的 panels_changed 帧", want) } } } // TestWirePanelHubDeclaresBuiltinSearchPanel 断言内置插件(web-search)的面板声明 // 真的进了目录 —— 没有这一步,契约面在运行期就是一份空快照(design/09 §5.5 的 dogfood // 也就无从验起)。顺带锁定「声明通过校验」:search.AdminPanels() 若被改坏,这里会红。 func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) { b := bus.New() defer b.Close() mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog()) defer mgr.Close(context.Background()) wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()}) hub := mgr.Panels() entries, err := hub.List(auth.RoleOwner) if err != nil { t.Fatalf("List: %v", err) } // 按 pluginID 定位,而不是数「恰好一条」:内置提供者会越来越多(本片新增 // mcp/servers 管理页),数总数的断言每加一页就假红一次,而它真正想守的是 // 「web-search/providers 在目录里,且形状仍是声明的那一版」。 entry, ok := findPanelEntry(entries, search.PanelPluginID, search.PanelIDProviders) if !ok { t.Fatalf("内置面板里没有 web-search/providers:%+v", entries) } // 形状也钉住:一个 form(写面)+ 一个 table(读面)。 if len(entry.Panel.Sections) != 2 || entry.Panel.Sections[0].Type != sdk.SectionForm || entry.Panel.Sections[1].Type != sdk.SectionTable { t.Fatalf("web-search/providers 的区块形状 = %+v,期望 form+table", entry.Panel.Sections) } for _, name := range search.ActionNames() { if _, ok := hub.Action(search.PanelPluginID, name); !ok { t.Fatalf("内置插件声明的动作 %q 未进白名单", name) } } // viewer 拿到的必须是裁掉 form 的只读投影(只剩那个表格区块)。 viewer, err := hub.List(auth.RoleViewer) if err != nil { t.Fatalf("List(viewer): %v", err) } viewerEntry, ok := findPanelEntry(viewer, search.PanelPluginID, search.PanelIDProviders) if !ok { t.Fatalf("viewer 快照里没有 web-search/providers:%+v", viewer) } if len(viewerEntry.Panel.Sections) != 1 || viewerEntry.Panel.Sections[0].Type != sdk.SectionTable { t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewerEntry.Panel.Sections) } } // findPanelEntry 在目录快照里按 (pluginId, panelId) 定位那一条。 func findPanelEntry(entries []paneldecl.Entry, pluginID, panelID string) (paneldecl.Entry, bool) { for _, e := range entries { if e.PluginID == pluginID && e.Panel.ID == panelID { return e, true } } return paneldecl.Entry{}, false } // TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。 func TestWirePanelHubNilPluginsIsNoop(t *testing.T) { wirePanelHub(&app{bus: bus.New(), log: testLog()}) } // TestWirePanelHubWithoutBusIsNoop 断言没有 bus 时也只告警、不 panic // (发布器内部判空,与 refresh store / key store 的降级口径一致)。 func TestWirePanelHubWithoutBusIsNoop(t *testing.T) { mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog()) defer mgr.Close(context.Background()) wirePanelHub(&app{plugins: mgr, log: testLog()}) hub := mgr.Panels() if hub == nil { t.Fatal("没有 bus 时仍应接上面板中枢(只是不发帧)") } if _, err := hub.Declare("web-search", nil); err != nil { t.Fatalf("Declare: %v", err) } }