From 93aa942e1e19080c73f4699a1d7dfaf22f24b870 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 09:55:33 +0800 Subject: [PATCH] =?UTF-8?q?test(plugins):=20deepsearch=20E2E=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=B8=A6=E8=B5=B0=E5=85=B1=E4=BA=AB=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=88=E9=99=84=E5=9B=9E=E5=BD=92=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:E2E 临时目录里拉起的插件实例在 teardown 时执行了 `docker compose stop -t 2`, 把线上正在用的 SearXNG 关掉,表现为「搜索后端起不来」。 - integration_test.go:把 ConfigRegistry 暴露给测试环境(其余不变) - deepsearch_e2e_test.go: - forbidStoppingSharedBackend:测试实例一律 stop_searxng_on_exit=false (配置表按内核约定先 RegisterDef 再 Set) - 新增 TestRealPlugin_DeepSearchKeepsSharedBackendOnStop:停掉插件实例后, 6 秒内 healthz 必须始终 200 —— 判据落在网络层,直接钉住「跑测试不能断线上搜索」 验证:三条 E2E 全过,且跑完 healthz 仍 200、容器 StartedAt 未变(未重启)。 --- internal/plugins/deepsearch_e2e_test.go | 196 ++++++++++++++++++++++++ internal/plugins/integration_test.go | 2 + 2 files changed, 198 insertions(+) create mode 100644 internal/plugins/deepsearch_e2e_test.go diff --git a/internal/plugins/deepsearch_e2e_test.go b/internal/plugins/deepsearch_e2e_test.go new file mode 100644 index 0000000..43c85e9 --- /dev/null +++ b/internal/plugins/deepsearch_e2e_test.go @@ -0,0 +1,196 @@ +//go:build linux || darwin + +package plugins + +import ( + "fmt" + "net/http" + "path/filepath" + "strings" + "testing" + "time" + + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" +) + +// forbidStoppingSharedBackend 给测试实例关上「退出时停后端」这道闸。 +// +// 本机 127.0.0.1:8888 的 SearXNG 是**线上服务在用的**。插件侧已保证「只关自己拉起的实例」, +// 这里再加一道:即便这个测试实例真的把它拉起来了,退出时也不关 —— 否则跑一次测试就断一次 +// 线上搜索(实测被这条坑过:E2E 临时目录里的插件实例在 teardown 时 `docker compose stop`, +// 表现为「搜索后端起不来」)。 +func forbidStoppingSharedBackend(t *testing.T, env *testPluginEnv) { + t.Helper() + if env.cfgReg == nil { + t.Fatal("测试环境没有配置注册表,无法关闭后端托管") + } + ps := env.cfgReg.PluginConfig("deepsearch") + // 配置表只在 RegisterDef 时创建(内核有意为之:读写不隐式建表)。 + // 插件启动时才会注册自己的 def,而我们要在那之前写入,所以这里先注册这个键。 + ps.RegisterDef(internalConfig.ConfigDef{Key: "stop_searxng_on_exit", Type: "bool", Default: "true"}) + if err := ps.Set("stop_searxng_on_exit", "false"); err != nil { + t.Fatalf("写 stop_searxng_on_exit=false 失败: %v", err) + } +} + +// deepsearch 插件的真实调用往返:内核 ExecuteTool → RPC → 插件子进程 → 本地 SearXNG → 结果回传。 +// +// 与 TestRealPlugin_ToolInvokeRoundTrip 的区别:那条只断言「链路通(拿到结果或拿到插件侧的错误)」, +// 这条断言**内容形状**——返回里必须有「摘要」与「引擎覆盖度」。这正是旧实现(抓 Bing HTML) +// 拿不到的东西,也是「搜索能力不行」的根因,所以它必须成为回归判据。 +// +// 前置:本机 127.0.0.1:8888 上跑着 SearXNG(部署见 /root/searxng-agent)。 +// 未启动时 fail 并给出可操作提示,而不是 skip —— 否则这条判据会在环境退化时静默失效。 +func TestRealPlugin_DeepSearchInvoke(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + plgDir := filepath.Join(env.tmpDir, "plugins") + installRealPlugin(t, plgDir, "deepsearch") + forbidStoppingSharedBackend(t, env) + + if err := env.pluginReg.Load(plgDir); err != nil { + t.Fatalf("加载插件: %v", err) + } + if env.pluginReg.Get("deepsearch") == nil { + t.Fatal("deepsearch 未经 proc 通道加载") + } + + var toolName string + for _, def := range env.stageHost.GetToolDefs() { + if strings.HasPrefix(def.Name, "deepsearch") && strings.HasSuffix(def.Name, "_search") { + toolName = def.Name + break + } + } + if toolName == "" { + t.Fatal("deepsearch 未注册检索工具") + } + t.Logf("调用工具 %s", toolName) + + // 用当初失败的那条查询做判据 + res, err := env.stageHost.ExecuteTool(toolName, map[string]interface{}{ + "query": "深度科技 deepin 开发者 被开除", + "count": float64(3), + }) + if err != nil { + if strings.Contains(err.Error(), "not found in any plugin") { + t.Fatalf("工具未注册到 stageHost: %v", err) + } + t.Fatalf("工具调用失败(检查本机 SearXNG 是否在 127.0.0.1:8888 运行): %v", err) + } + if res == nil { + t.Fatal("工具返回 nil 且无错误") + } + + text := fmt.Sprintf("%v", res) + t.Logf("工具返回前 500 字:\n%s", truncRunes(text, 500)) + + if !strings.Contains(text, "摘要:") { + t.Errorf("返回内容缺少摘要——这正是旧实现拿不到的部分:\n%s", truncRunes(text, 800)) + } + if !strings.Contains(text, "覆盖:") { + t.Errorf("返回内容缺少引擎覆盖度(模型据此判断可信度):\n%s", truncRunes(text, 800)) + } + if !strings.Contains(text, "http") { + t.Errorf("返回内容缺少结果链接:\n%s", truncRunes(text, 800)) + } +} + +// deepsearch_status 也走一遍真实调用:它把「后端是否可用、哪些引擎在出结果」暴露成工具, +// 出问题时 agent 可以先自检,而不是盲目换词重搜。 +func TestRealPlugin_DeepSearchStatusInvoke(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + plgDir := filepath.Join(env.tmpDir, "plugins") + installRealPlugin(t, plgDir, "deepsearch") + forbidStoppingSharedBackend(t, env) + if err := env.pluginReg.Load(plgDir); err != nil { + t.Fatalf("加载插件: %v", err) + } + + var toolName string + for _, def := range env.stageHost.GetToolDefs() { + if strings.HasPrefix(def.Name, "deepsearch") && strings.HasSuffix(def.Name, "_status") { + toolName = def.Name + break + } + } + if toolName == "" { + t.Fatal("deepsearch 未注册自检工具") + } + + res, err := env.stageHost.ExecuteTool(toolName, map[string]interface{}{"probe": "test"}) + if err != nil { + t.Fatalf("自检调用失败(检查本机 SearXNG 是否运行): %v", err) + } + text := fmt.Sprintf("%v", res) + t.Logf("自检返回:%s", truncRunes(text, 400)) + + for _, want := range []string{"healthz", "search_ok", "engines_returning_results"} { + if !strings.Contains(text, want) { + t.Errorf("自检结果缺少字段 %q:%s", want, truncRunes(text, 500)) + } + } +} + +func truncRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +// 插件的停止**不得带走共享后端**:这是「搜索后端起不来」的直接回归判据。 +// 判据落在网络层(healthz 仍然 200),而不是「有没有执行 docker 命令」。 +func TestRealPlugin_DeepSearchKeepsSharedBackendOnStop(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + requireSearxngUp(t) + + plgDir := filepath.Join(env.tmpDir, "plugins") + installRealPlugin(t, plgDir, "deepsearch") + forbidStoppingSharedBackend(t, env) + if err := env.pluginReg.Load(plgDir); err != nil { + t.Fatalf("加载插件: %v", err) + } + if env.pluginReg.Get("deepsearch") == nil { + t.Fatal("deepsearch 未加载") + } + + // 停掉这个插件实例(相当于测试 teardown / 另一个 daemon 退出) + if err := env.pluginReg.StopAndUnload("deepsearch"); err != nil { + t.Fatalf("停止插件失败: %v", err) + } + + // 给「如果它真去关了」留出执行窗口:stop -t 2 最多 2 秒 + deadline := time.Now().Add(6 * time.Second) + for time.Now().Before(deadline) { + if !searxngHealthy() { + t.Fatalf("插件停止把共享后端带走了(healthz 不再是 200)—— 线上搜索会因此不可用") + } + time.Sleep(500 * time.Millisecond) + } + t.Log("插件已停止,共享后端仍在服务") +} + +// requireSearxngUp 前置检查:后端不在时 fail 并给出可操作提示(不 skip,避免环境退化时静默失效) +func requireSearxngUp(t *testing.T) { + t.Helper() + if !searxngHealthy() { + t.Fatal("本机 127.0.0.1:8888 的 SearXNG 不可用;先 `cd /root/searxng-agent && docker compose up -d`") + } +} + +func searxngHealthy() bool { + cl := &http.Client{Timeout: 3 * time.Second} + resp, err := cl.Get("http://127.0.0.1:8888/healthz") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} diff --git a/internal/plugins/integration_test.go b/internal/plugins/integration_test.go index e261cec..e36a05f 100644 --- a/internal/plugins/integration_test.go +++ b/internal/plugins/integration_test.go @@ -28,6 +28,7 @@ type testPluginEnv struct { memDB *memory.GraphDB ks *knowledge.Store docStore *doc.Store + cfgReg *internalConfig.ConfigRegistry } func setupIntegration(t *testing.T) *testPluginEnv { @@ -101,6 +102,7 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t memDB: memDB, ks: ks, docStore: docStore, + cfgReg: cfgReg, } }