// config_plugins_test.go - regression guards for engine.Config.Plugins wiring. // // Before this commit, Config.Plugins was defined on the SDK-facing Config // struct but never read anywhere in engine.New -- a dead field siblings to // Config.MCPServers (fixed in 57a06c6). The fix in this commit wires // loadConfigPlugins into New. The tests below lock that wiring in place so // any future refactor that stops reading the field fails CI immediately. // // 本 commit 之前, Config.Plugins 在 SDK 面向的 Config struct 里定义但 // engine.New 从未读过它 -- 是和 Config.MCPServers (57a06c6 已修) 同类 // 的死字段. 本 commit 把 loadConfigPlugins wire 进 New. 下面的测试把 // 这条 wiring 锁住, 未来任何 refactor 如果停止读此字段会被 CI 直接拦下. package engine import ( "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/plugin" ) // TestConfigPlugins_FieldIsRead is the primary regression guard: it proves // engine.New actually reads Config.Plugins and routes each entry through // plugin.Host.Load. We verify both the observer summary event (cheap, // decoupled from Host internals) AND the host registry state (direct proof // that Load was called). If the field silently stops being read, both // checks fail simultaneously. // // TestConfigPlugins_FieldIsRead 是核心 regression guard: 证明 engine.New // 真的读了 Config.Plugins 且每个 entry 都走了 plugin.Host.Load. 两条证据 // 并存: observer 汇总事件 (成本低, 与 Host 内部解耦) 以及 host registry // 实际状态 (Load 确实被调过的直接证据). 未来若字段再次被静默不读, 两条 // 断言一起失败. func TestConfigPlugins_FieldIsRead(t *testing.T) { obs := &MockObserver{} cfg := testConfig() cfg.Observer = obs cfg.Plugins = []plugin.Definition{ {Name: "alpha", Description: "first", Source: "/opt/plugins/alpha"}, {Name: "beta", Description: "second", Source: "/opt/plugins/beta"}, } eng, err := New(cfg) if err != nil { t.Fatalf("engine.New: %v", err) } defer eng.Close() last := obs.LastEvent("config_plugins_loaded") if last == nil { t.Fatal("config_plugins_loaded event missing (loadConfigPlugins never ran?)") } if pc, _ := last.Data["plugin_count"].(int); pc != 2 { t.Errorf("plugin_count: want 2, got %v", last.Data["plugin_count"]) } for _, name := range []string{"alpha", "beta"} { if _, ok := eng.plugins.Get(name); !ok { t.Errorf("plugin %q not found in host registry after New; Host.Load was not invoked", name) } } } // TestConfigPlugins_EmptyNameSkipped verifies an entry with an empty Name // produces a config_plugin_skipped observer event and does not register // an empty-Name placeholder (which would pollute the host registry with a // "" key and collide on future inserts). // // TestConfigPlugins_EmptyNameSkipped 验证空 Name 的 entry 会触发 // config_plugin_skipped 事件, 且不会以 "" 为 key 污染 host registry // (否则将来插入同名会撞上). func TestConfigPlugins_EmptyNameSkipped(t *testing.T) { obs := &MockObserver{} cfg := testConfig() cfg.Observer = obs cfg.Plugins = []plugin.Definition{ {Name: "", Description: "no name", Source: "/opt/plugins/anon"}, {Name: "real", Description: "keeps loading", Source: "/opt/plugins/real"}, } eng, err := New(cfg) if err != nil { t.Fatalf("engine.New: %v", err) } defer eng.Close() if got := obs.EventCount("config_plugin_skipped"); got != 1 { t.Errorf("config_plugin_skipped count: want 1, got %d", got) } last := obs.LastEvent("config_plugins_loaded") if last == nil { t.Fatal("config_plugins_loaded event missing") } if pc, _ := last.Data["plugin_count"].(int); pc != 1 { t.Errorf("plugin_count: want 1 (empty-Name skipped), got %v", last.Data["plugin_count"]) } if _, ok := eng.plugins.Get(""); ok { t.Error(`host registry contains "" key; empty-Name entry leaked past the skip guard`) } if _, ok := eng.plugins.Get("real"); !ok { t.Error("plugin \"real\" missing; skip guard broke sibling entries") } } // TestConfigPlugins_EmptySliceIsNoop verifies the common path -- SDK // consumers who do not use this field see no overhead and no observer // noise. Mirrors the semantics of TestConfigMCPServers_EmptySliceIsNoop. // // TestConfigPlugins_EmptySliceIsNoop 验证通用路径: 不用此字段的 SDK // 消费方没任何开销, observer 无噪音事件. 对齐 // TestConfigMCPServers_EmptySliceIsNoop. func TestConfigPlugins_EmptySliceIsNoop(t *testing.T) { obs := &MockObserver{} cfg := testConfig() cfg.Observer = obs // cfg.Plugins left nil intentionally. eng, err := New(cfg) if err != nil { t.Fatalf("engine.New: %v", err) } defer eng.Close() for _, ev := range []string{ "config_plugin_skipped", "config_plugin_load_failed", "config_plugins_loaded", } { if got := obs.EventCount(ev); got != 0 { t.Errorf("%s count: want 0 for nil Plugins, got %d", ev, got) } } }