// loader_test.go - 加载器,ValidateManifest,RegisterBuiltin 的单元测试. // // 覆盖: // - discoverWithSource 来源标注 // - loadPluginWithErrors 非致命降级 // - ValidateManifest 预检 // - RegisterBuiltin SDK 内置注册 // - Host.LoadAll 返回 LoadResult // - manifest mcpServers/mcp_servers 双字段兼容 package plugin import ( "encoding/json" "os" "path/filepath" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // writeManifest 在指定目录写入 plugin.json,content 为 map. func writeManifest(t *testing.T, dir string, content map[string]any) { t.Helper() data, err := json.MarshalIndent(content, "", " ") if err != nil { t.Fatalf("marshal manifest: %v", err) } if err := os.WriteFile(filepath.Join(dir, "plugin.json"), data, 0644); err != nil { t.Fatalf("write manifest: %v", err) } } // ── discoverWithSource ──────────────────────────────────────────────────────── func TestDiscoverWithSource_UserLevel(t *testing.T) { userDir := t.TempDir() pluginDir := filepath.Join(userDir, "my-plugin") os.MkdirAll(pluginDir, 0755) writeManifest(t, pluginDir, map[string]any{"name": "my-plugin"}) stubs := discoverWithSource([]string{userDir}) if len(stubs) != 1 { t.Fatalf("应发现 1 个插件, 实际 %d", len(stubs)) } if stubs[0].Source != SourceUser { t.Errorf("用户目录的插件应为 SourceUser, 实际 %v", stubs[0].Source) } } func TestDiscoverWithSource_ProjectLevel(t *testing.T) { userDir := t.TempDir() projDir := t.TempDir() // 项目级目录的插件 pluginDir := filepath.Join(projDir, "proj-plugin") os.MkdirAll(pluginDir, 0755) writeManifest(t, pluginDir, map[string]any{"name": "proj-plugin"}) stubs := discoverWithSource([]string{userDir, projDir}) if len(stubs) != 1 { t.Fatalf("应发现 1 个插件, 实际 %d", len(stubs)) } if stubs[0].Source != SourceProject { t.Errorf("项目目录的插件应为 SourceProject, 实际 %v", stubs[0].Source) } } func TestDiscoverWithSource_ProjectOverridesUser(t *testing.T) { userDir := t.TempDir() projDir := t.TempDir() // 两个搜索路径都有名为 "shared" 的插件 for _, dir := range []string{userDir, projDir} { pluginDir := filepath.Join(dir, "shared") os.MkdirAll(pluginDir, 0755) writeManifest(t, pluginDir, map[string]any{"name": "shared"}) } stubs := discoverWithSource([]string{userDir, projDir}) if len(stubs) != 1 { t.Fatalf("同名插件应只出现一次, 实际 %d", len(stubs)) } // 项目级(索引1)覆盖用户级(索引0) if stubs[0].Source != SourceProject { t.Errorf("项目级应覆盖用户级, 实际 Source=%v", stubs[0].Source) } } // ── loadPluginWithErrors ────────────────────────────────────────────────────── func TestLoadPluginWithErrors_Success(t *testing.T) { dir := t.TempDir() writeManifest(t, dir, map[string]any{ "name": "ok-plugin", "version": "1.0.0", }) p, errs := loadPluginWithErrors(pluginStub{Path: dir, Source: SourceUser}, execenv.DefaultExecutor{}) if p == nil { t.Fatal("应成功加载插件") } if len(errs) != 0 { t.Errorf("无错误应为空列表, 实际: %v", errs) } if p.Source != SourceUser { t.Errorf("Source = %v, 期望 SourceUser", p.Source) } } func TestLoadPluginWithErrors_MissingManifest(t *testing.T) { dir := t.TempDir() // 不写 plugin.json p, errs := loadPluginWithErrors(pluginStub{Path: dir, Source: SourceUser}, execenv.DefaultExecutor{}) if p != nil { t.Error("清单不存在应返回 nil plugin") } if len(errs) == 0 { t.Fatal("清单不存在应返回错误") } if errs[0].Code != ErrManifestInvalid && errs[0].Code != ErrManifestNotFound { t.Errorf("错误码应为 manifest 相关, 实际 %v", errs[0].Code) } } func TestLoadPluginWithErrors_WarningsForSkillsFail(t *testing.T) { dir := t.TempDir() // skills_path 指向不存在的目录--不应是致命错误 writeManifest(t, dir, map[string]any{ "name": "warn-plugin", "skills_path": "/nonexistent/skills/dir", }) p, errs := loadPluginWithErrors(pluginStub{Path: dir, Source: SourceUser}, execenv.DefaultExecutor{}) // 插件应该加载成功(skills 失败是 warning) if p == nil { t.Fatal("技能目录不存在不应是致命错误") } // 无致命错误(warnings 可能有,不在 errs 里) for _, e := range errs { if e.Code == ErrManifestNotFound || e.Code == ErrManifestInvalid { t.Errorf("不应有清单致命错误: %v", e) } } } // ── ValidateManifest ────────────────────────────────────────────────────────── func TestValidateManifest_Valid(t *testing.T) { dir := t.TempDir() writeManifest(t, dir, map[string]any{ "name": "valid-plugin", "description": "A valid plugin", "version": "1.0.0", }) vr := ValidateManifest(dir) if !vr.Valid { t.Errorf("有效清单应通过验证, errors: %v", vr.Errors) } } func TestValidateManifest_NotFound(t *testing.T) { vr := ValidateManifest("/nonexistent/dir/xyz") if vr.Valid { t.Error("不存在的目录应验证失败") } if len(vr.Errors) == 0 { t.Error("应有错误") } } func TestValidateManifest_EmptyDescription_Warning(t *testing.T) { dir := t.TempDir() writeManifest(t, dir, map[string]any{ "name": "no-desc-plugin", // 无 description }) vr := ValidateManifest(dir) if !vr.Valid { t.Error("无 description 不应阻断验证(只是 Warning)") } if len(vr.Warnings) == 0 { t.Error("缺少 description 应产生 Warning") } } func TestValidateManifest_InvalidName(t *testing.T) { dir := t.TempDir() writeManifest(t, dir, map[string]any{ "name": "invalid name!", // 含空格和感叹号 }) vr := ValidateManifest(dir) if vr.Valid { t.Error("无效名称应验证失败") } } // ── RegisterBuiltin ─────────────────────────────────────────────────────────── func TestRegisterBuiltin_Success(t *testing.T) { h := NewHost(execenv.DefaultExecutor{}) err := h.RegisterBuiltin(BuiltinDef{ Name: "builtin-tool", Description: "A builtin plugin", Version: "1.0.0", }) if err != nil { t.Fatalf("RegisterBuiltin 失败: %v", err) } p, ok := h.Get("builtin-tool") if !ok { t.Fatal("注册后应能 Get") } if p.Source != SourceBuiltin { t.Errorf("Source = %v, 期望 SourceBuiltin", p.Source) } if !p.Enabled { t.Error("内置插件默认应启用") } } func TestRegisterBuiltin_DefaultVersion(t *testing.T) { h := NewHost(execenv.DefaultExecutor{}) h.RegisterBuiltin(BuiltinDef{Name: "no-version"}) p, _ := h.Get("no-version") if p.Version != "0.0.0" { t.Errorf("Version = %q, 期望 \"0.0.0\"", p.Version) } } func TestRegisterBuiltin_ConflictWithFileSystemPlugin(t *testing.T) { h := NewHost(execenv.DefaultExecutor{}) // 先加载一个文件系统插件 h.Load(Definition{Name: "my-plugin", Source: "/some/path"}) // 再注册同名内置插件,应报冲突 err := h.RegisterBuiltin(BuiltinDef{Name: "my-plugin"}) if err == nil { t.Error("与文件系统插件同名时应报 ErrBuiltinConflict") } pe, ok := err.(PluginError) if !ok || pe.Code != ErrBuiltinConflict { t.Errorf("错误应为 ErrBuiltinConflict, 实际: %v", err) } } func TestRegisterBuiltin_OverwriteExistingBuiltin(t *testing.T) { h := NewHost(execenv.DefaultExecutor{}) h.RegisterBuiltin(BuiltinDef{Name: "b", Description: "old"}) // 重复注册同名内置插件,允许覆盖(不报错) err := h.RegisterBuiltin(BuiltinDef{Name: "b", Description: "new"}) if err != nil { t.Fatalf("重复注册内置插件应允许覆盖: %v", err) } p, _ := h.Get("b") if p.Description != "new" { t.Errorf("覆盖后 Description 应为 \"new\", 实际 %q", p.Description) } } func TestRegisterBuiltin_WithSkills(t *testing.T) { h := NewHost(execenv.DefaultExecutor{}) skills := []*Skill{{Name: "test:my-skill", RawName: "my-skill", PluginName: "test"}} h.RegisterBuiltin(BuiltinDef{ Name: "test", Skills: skills, }) allSkills := h.GetAllSkills() if len(allSkills) != 1 { t.Errorf("期望 1 个技能, 实际 %d", len(allSkills)) } } // ── Host.LoadAll 返回 LoadResult ────────────────────────────────────────────── func TestHostLoadAll_ReturnsLoadResult(t *testing.T) { rootDir := t.TempDir() // 创建一个有效插件 pluginDir := filepath.Join(rootDir, "good-plugin") os.MkdirAll(pluginDir, 0755) writeManifest(t, pluginDir, map[string]any{ "name": "good-plugin", }) h := NewHost(execenv.DefaultExecutor{}) result := h.LoadAll([]string{rootDir}) if len(result.Enabled) != 1 { t.Errorf("应有 1 个启用插件, 实际 %d", len(result.Enabled)) } if result.Enabled[0].Name != "good-plugin" { t.Errorf("Enabled[0].Name = %q", result.Enabled[0].Name) } } func TestHostLoadAll_BuiltinPreserved(t *testing.T) { // 预注册内置插件后调用 LoadAll,内置插件应被保留 h := NewHost(execenv.DefaultExecutor{}) h.RegisterBuiltin(BuiltinDef{Name: "core"}) result := h.LoadAll([]string{}) // 空路径,只有内置 found := false for _, p := range result.Enabled { if p.Name == "core" { found = true } } if !found { // 内置插件可能不在 LoadAll 的 Enabled 里(因为已在 plugins map 中), // 但通过 Get 应仍能访问 p, ok := h.Get("core") if !ok || !p.Enabled { t.Error("LoadAll 后内置插件应仍存在且启用") } } } // ── manifest mcpServers/mcp_servers 双字段兼容 ──────────────────────────────── func TestManifest_MCPServers_NewFormat(t *testing.T) { // 新格式:mcpServers(Claude Code 格式) dir := t.TempDir() writeManifest(t, dir, map[string]any{ "name": "new-format", "mcpServers": map[string]any{ "myserver": map[string]any{ "transport": "stdio", "command": "node", "args": []string{"server.js"}, }, }, }) m, err := LoadManifest(dir) if err != nil { t.Fatalf("加载新格式清单失败: %v", err) } if len(m.MCPServers) != 1 { t.Errorf("mcpServers 应有 1 个服务器, 实际 %d", len(m.MCPServers)) } } func TestManifest_MCPServers_LegacyFormat(t *testing.T) { // 旧格式:mcp_servers(向后兼容) dir := t.TempDir() writeManifest(t, dir, map[string]any{ "name": "legacy-format", "mcp_servers": map[string]any{ "myserver": map[string]any{ "transport": "stdio", "command": "python", "args": []string{"server.py"}, }, }, }) m, err := LoadManifest(dir) if err != nil { t.Fatalf("加载旧格式清单失败: %v", err) } if len(m.MCPServers) != 1 { t.Errorf("mcp_servers 旧字段兼容性失败, 应有 1 个服务器, 实际 %d", len(m.MCPServers)) } } func TestManifest_MCPServers_NewPreferredOverLegacy(t *testing.T) { // 同时存在两个字段,新字段(mcpServers)优先 raw := []byte(`{ "name": "both", "mcpServers": { "new-server": {"transport": "stdio", "command": "node"} }, "mcp_servers": { "old-server1": {"transport": "stdio", "command": "python"}, "old-server2": {"transport": "stdio", "command": "ruby"} } }`) dir := t.TempDir() os.WriteFile(filepath.Join(dir, "plugin.json"), raw, 0644) m, err := LoadManifest(dir) if err != nil { t.Fatalf("加载清单失败: %v", err) } // 新字段有 1 个服务器,旧字段有 2 个,应选新字段(1 个) if len(m.MCPServers) != 1 { t.Errorf("新字段应优先于旧字段, 期望 1 个服务器, 实际 %d", len(m.MCPServers)) } if _, ok := m.MCPServers["new-server"]; !ok { t.Error("应保留 mcpServers 中的 new-server") } }