Files
Cyrene_v2/cmd/cyrene/wire_panels_test.go
T
AskaEth 2421a8dac9 feat(plugin): 组合根装配「MCP 服务器」页(声明 + 动作处理器成对)
- wirePanelHub 在 AttachPanels / declareBuiltinPanels 之后调 AttachMCPPanel:
  一处完成「声明页面」+「登记 7 个动作处理器」,缺任一步就是面板在、动作全 501。
- wire_panels_test 的两条「内置面板恰好 1 条」断言改成按 pluginID 定位
  (web-search/providers),并加钉区块形状(form+table;viewer 只剩 table)。
  接线落地后目录里确实是两条(mcp/10 + web-search/20),旧断言是事实过期而非回归。
2026-10-24 19:38:38 +08:00

163 lines
6.3 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 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)
}
}