Files
homeagent-sdk/example/deepsearch/plugin_test.go
JianFeeeee 7c0b7a1fb0 feat(deepsearch): 联网检索插件 + SearXNG 生命周期托管
把原 websearch 示例改名为 deepsearch(目录/go.mod/plg.json/工具前缀/README 全量对齐,
工具名 websearch_* → deepsearch_*)。

新增 searxng.go:插件自己托管搜索后端
- 启动探 healthz:已在跑则直接接管(不重启),没跑就 docker compose up -d 并等就绪
- 关闭动作注册为 stop handler(幂等、限时 4s < 内核 5s 宽限期)
- 配置 manage_searxng / searxng_dir / stop_searxng_on_exit
- 崩溃/被 kill 时不关后端(下次启动接管):安全失败方向
- 可注入 cmdRunner + 时间预算,8 项单测离线覆盖接管/拉起/失败/幂等/保留

契约依据(internal/plugin/proc):停止插件 = plugin.stop → RunStopHandlers(LIFO、
幂等)→ Stop() → exit(0),宽限期 5s;stdin 关闭同路径。

验证:19 项单测(18 通过 + 1 联调跳过)、-race 干净、vet/gofmt 干净;内核 E2E 真调用
返回 58 条/37 条、58/58 带摘要;线上 daemon 实测 stop handler 与冷启动(约 3s)。
2026-09-12 23:16:23 +08:00

289 lines
11 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 (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func newTestPlugin(t *testing.T, h http.HandlerFunc) (*Plugin, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
p := &Plugin{
name: "deepsearch",
searxURL: srv.URL,
maxItems: 5,
language: "zh-CN",
fetchMax: 1000,
userAgent: "test-agent",
http: srv.Client(),
}
return p, srv
}
// 一份贴近真实 SearXNG 的响应:含重复 URL、缺摘要、多引擎、无响应引擎
const sampleResponse = `{
"query": "deepin 被开除",
"results": [
{"url":"https://www.zhihu.com/question/1?utm_source=x","title":"网传统信内核开发工程师因没穿西服被开除","content":"截止1月9日最新情况…","engines":["duckduckgo","brave"],"score":9.5,"publishedDate":"2026-09-10T00:00:00"},
{"url":"https://www.zhihu.com/question/1","title":"网传统信内核开发工程师因没穿西服被开除(重复项)","content":"重复条目","engines":["brave"],"score":1.0},
{"url":"https://www.163.com/dy/article/KIQURODQ.html","title":"离谱!传某信创操作系统大厂因西装开除核心开发者","content":"一位负责Linux内核开发的核心工程师…","engines":["brave","quark"],"score":7.2},
{"url":"https://bbs.deepin.org.cn/zh","title":"deepin官方论坛","content":"","engines":["duckduckgo"],"score":2.0}
],
"answers": [],
"suggestions": ["deepin 王勇 离职"],
"unresponsive_engines": [["baidu","CAPTCHA"],["sogou","unexpected crash"]],
"timings": {"search": 1.2}
}`
// 1) 检索:去重 + 按分数排序 + 摘要/覆盖度输出
func TestSearchDedupAndFormat(t *testing.T) {
var gotQuery url.Values
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/search" {
gotQuery = r.URL.Query()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(sampleResponse))
return
}
http.NotFound(w, r)
})
res, err := p.handleSearch(map[string]interface{}{"query": "deepin 被开除", "count": float64(5)})
if err != nil {
t.Fatalf("err: %v", err)
}
if gotQuery.Get("format") != "json" {
t.Errorf("必须要求 json 输出,实际 %q", gotQuery.Get("format"))
}
if gotQuery.Get("limit") != "5" {
t.Errorf("limit 未生效: %q", gotQuery.Get("limit"))
}
txt := res.(map[string]interface{})["content"].(string)
// utm_source 应被规范化掉,重复项只剩一条
if n := strings.Count(txt, "zhihu.com/question/1"); n != 1 {
t.Errorf("URL 未正确去重(出现 %d 次):\n%s", n, txt)
}
if !strings.Contains(txt, "网传统信内核开发工程师") {
t.Errorf("缺少标题: %s", txt)
}
if !strings.Contains(txt, "摘要:") {
t.Errorf("应输出摘要: %s", txt)
}
if !strings.Contains(txt, "baidu(CAPTCHA)") {
t.Errorf("应回报无响应引擎(让模型知道覆盖度): %s", txt)
}
if !strings.Contains(txt, "duckduckgo") || !strings.Contains(txt, "quark") {
t.Errorf("应回报引擎覆盖: %s", txt)
}
// 高分条目应排在前面
if strings.Index(txt, "统信内核开发工程师") > strings.Index(txt, "离谱!") {
t.Errorf("未按分数排序:\n%s", txt)
}
}
// 2) 403未开 json必须给出可操作提示而不是裸错误
func TestSearchForbiddenHint(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("Forbidden"))
})
_, err := p.handleSearch(map[string]interface{}{"query": "x"})
if err == nil {
t.Fatal("应返回错误")
}
msg := err.Error()
if !strings.Contains(msg, "403") || !strings.Contains(msg, "formats") {
t.Errorf("403 提示应指向 json/limiter 配置,实际: %s", msg)
}
}
// 3) 空结果:要给出原因与下一步建议
func TestSearchEmptyHint(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"query":"x","results":[],"suggestions":["换个词"],"unresponsive_engines":[["google","CAPTCHA"]]}`))
})
res, err := p.handleSearch(map[string]interface{}{"query": "x"})
if err != nil {
t.Fatalf("err: %v", err)
}
txt := res.(map[string]interface{})["content"].(string)
for _, want := range []string{"未返回结果", "google(CAPTCHA)", "换个词", "deepsearch_news"} {
if !strings.Contains(txt, want) {
t.Errorf("空结果提示缺少 %q: %s", want, txt)
}
}
}
// 4) 新闻:应带 categories=news 与 time_range=week新闻为空时回退 general
func TestNewsParamsAndFallback(t *testing.T) {
var calls []url.Values
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.URL.Query())
if r.URL.Query().Get("categories") == "news" {
_, _ = w.Write([]byte(`{"query":"n","results":[]}`))
return
}
_, _ = w.Write([]byte(`{"query":"n","results":[{"url":"https://a.com/1","title":"回退结果","content":"内容","engines":["brave"],"score":1}]}`))
})
res, err := p.handleNews(map[string]interface{}{"query": "某事"})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(calls) != 2 {
t.Fatalf("新闻为空时应回退 general实际调用 %d 次", len(calls))
}
if calls[0].Get("categories") != "news" || calls[0].Get("time_range") != "week" {
t.Errorf("首次应为 news + week实际 categories=%q time_range=%q", calls[0].Get("categories"), calls[0].Get("time_range"))
}
if tmp := res.(map[string]interface{})["content"].(string); !strings.Contains(tmp, "回退结果") {
t.Errorf("回退结果未被采用: %s", tmp)
}
}
// 5) 正文抽取:去脚本/样式/导航,保留 article
func TestFetchExtractsArticle(t *testing.T) {
page := `<!doctype html><html><head><title>测试标题 - 站点</title>
<style>.x{color:red}</style><script>var secret="SHOULD_NOT_APPEAR";</script></head>
<body><nav>导航链接</nav><article>
<p>第一段正文,包含关键事实。</p><p>第二段正文。</p>
</article><footer>页脚</footer></body></html>`
var srvURL string
p, srv := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(page))
})
srvURL = srv.URL
// 注意:不要用 example.com 之类真实域名——本机 DNS/proxy 会把它们转走,测试会飘
res, err := p.handleFetch(map[string]interface{}{"url": srvURL + "/a"})
if err != nil {
t.Fatalf("err: %v", err)
}
txt := res.(map[string]interface{})["content"].(string)
if !strings.Contains(txt, "第一段正文") {
t.Errorf("正文丢失: %s", txt)
}
if strings.Contains(txt, "SHOULD_NOT_APPEAR") {
t.Errorf("脚本内容不应出现: %s", txt)
}
if strings.Contains(txt, "导航链接") || strings.Contains(txt, "页脚") {
t.Errorf("导航/页脚应被剥离: %s", txt)
}
if !strings.Contains(txt, "测试标题") {
t.Errorf("标题应被提取: %s", txt)
}
}
// 6) 深检索:候选 + 正文证据;单篇失败不应导致整体失败
func TestDeepSearch(t *testing.T) {
var srvURL string // 处理函数先于 server 存在,故用闭包变量回填
p, srv := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search":
_, _ = w.Write([]byte(`{"query":"d","results":[
{"url":"` + srvURL + `/ok1","title":"好文一","content":"摘要一","engines":["brave"],"score":3},
{"url":"` + srvURL + `/bad","title":"打不开的","content":"摘要二","engines":["brave"],"score":2},
{"url":"` + srvURL + `/ok2","title":"好文二","content":"摘要三","engines":["brave"],"score":1}]}`))
case "/ok1", "/ok2":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<html><body><article><p>正文内容 " + r.URL.Path + "</p></article></body></html>"))
case "/bad":
w.WriteHeader(http.StatusForbidden)
default:
http.NotFound(w, r)
}
})
srvURL = srv.URL
res, err := p.handleDeep(map[string]interface{}{"query": "d", "top_k": float64(3), "max_chars": float64(500)})
if err != nil {
t.Fatalf("err: %v", err)
}
txt := res.(map[string]interface{})["content"].(string)
for _, want := range []string{"候选清单", "正文证据", "正文内容 /ok1", "正文内容 /ok2", "抓取失败"} {
if !strings.Contains(txt, want) {
t.Errorf("深检索输出缺少 %q:\n%s", want, txt)
}
}
}
// 7) 自检:健康检查 + 探测检索 + 引擎覆盖统计
func TestStatusReportsEngines(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
_, _ = w.Write([]byte("OK"))
return
}
_, _ = w.Write([]byte(sampleResponse))
})
res, err := p.handleStatus(map[string]interface{}{})
if err != nil {
t.Fatalf("err: %v", err)
}
m := res.(map[string]interface{})
if m["healthz"] != 200 {
t.Errorf("healthz 应为 200实际 %v", m["healthz"])
}
if m["search_ok"] != true {
t.Errorf("search_ok 应为 true%v", m["search_ok"])
}
engs, ok := m["engines_returning_results"].(map[string]int)
if !ok || engs["brave"] == 0 || engs["quark"] == 0 {
t.Errorf("引擎统计不正确: %#v", m["engines_returning_results"])
}
}
// 8) 摘要压成一行并按字符截断(避免巨长摘要吃掉上下文)
func TestOneLineTruncate(t *testing.T) {
got := oneLine("第一行\n第二行\t第三行", 5)
if strings.Contains(got, "\n") {
t.Errorf("应为单行: %q", got)
}
if r := []rune(got); len(r) != 6 { // 5 字符 + 省略号
t.Errorf("截断长度不符: %q (%d runes)", got, len(r))
}
}
// 9) 正文抽取长度上限生效
func TestHtmlToTextTruncation(t *testing.T) {
long := strings.Repeat("字", 5000)
_, text := htmlToText("<html><body><article><p>"+long+"</p></article></body></html>", 100)
if !strings.Contains(text, "已截断") {
t.Errorf("超长正文应被截断: %d", len([]rune(text)))
}
}
// 10) 非 http(s) 协议应被拒绝
func TestFetchRejectsBadScheme(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
if _, err := p.handleFetch(map[string]interface{}{"url": "file:///etc/passwd"}); err == nil {
t.Fatal("file:// 应被拒绝")
}
if _, err := p.handleFetch(map[string]interface{}{"url": "javascript:alert(1)"}); err == nil {
t.Fatal("javascript: 应被拒绝")
}
}
// 11) raw 模式返回结构化 JSON排查用
func TestSearchRawMode(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(sampleResponse))
})
res, err := p.handleSearch(map[string]interface{}{"query": "q", "raw": true})
if err != nil {
t.Fatalf("err: %v", err)
}
m, ok := res.(*searxResponse)
if !ok {
t.Fatalf("raw 应返回结构化响应,实际 %T", res)
}
if len(m.Results) != 4 {
t.Errorf("结果数应为 4raw 不去重),实际 %d", len(m.Results))
}
if _, err := json.Marshal(m); err != nil {
t.Errorf("结构化结果应可序列化: %v", err)
}
}