package main import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" ) // 本文件钉住切片 WS1-A 的组合根接线:`WEB_SEARCH_ENDPOINT` / `WEB_SEARCH_TIMEOUT` // 经 `a.ioToolOptions()` 传进 io 工具集,端点不可用时 web-search **不进注册面**。 // // 断言落在**注册表**上(`toolNames`,即模型真正看到的那一面),而不是源码字符串 —— // 源码接缝只作为最后一段的防回归补充。 // wsTestEnvFile 写一份只含 JWT_SECRET(唯一的必填键)加若干自定义行的 .env, // 返回路径。走 config.Load 是为了让「未写这个键 ⇒ 用标签默认值」这条链真实发生。 func wsTestEnvFile(t *testing.T, extra ...string) string { t.Helper() lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...) path := filepath.Join(t.TempDir(), ".env") if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { t.Fatalf("write .env: %v", err) } return path } // wsLoadConfig 按给定 .env 行加载配置(进程环境优先级更高,由 config 层负责)。 // // DataDir 一律指到本用例自己的临时目录:web-search 的可变源 // (`$DATA_DIR/config/plugins/web-search.json`,PLG-4)会**按启动期语义**读/播种它, // 默认的 `./data` 会让并行用例互相踩同一份文件(且把仓库工作目录写脏)。 func wsLoadConfig(t *testing.T, extra ...string) *config.Config { t.Helper() cfg, err := config.Load(wsTestEnvFile(t, extra...)) if err != nil { t.Fatalf("config.Load: %v", err) } cfg.DataDir = t.TempDir() return cfg } // wsSurfaceApp 用组合根的**同一条路**把 io 工具注册进能力面:ioToolOptions → // iotools.New → dispatcher.Register。logOut 非空时用它当日志出口(用于断言 WARN)。 func wsSurfaceApp(t *testing.T, cfg *config.Config, logOut *bytes.Buffer) *app { t.Helper() caps := registry.New() dispatcher, err := tool.New(caps) if err != nil { t.Fatalf("tool.New: %v", err) } a := &app{cfg: cfg, log: testLog()} if logOut != nil { a.log = logger.New(logger.Options{Out: logOut, Level: "debug"}) } set, err := iotools.New(cfg, a.ioToolOptions()...) if err != nil { t.Fatalf("iotools.New: %v", err) } for _, tl := range set { if rerr := dispatcher.Register(tl); rerr != nil { t.Fatalf("register %s: %v", tl.Name(), rerr) } } a.caps = caps a.dispatcher = dispatcher return a } // wsHasTool 报告注册表(模型可见的工具面)里有没有这个名字。 func wsHasTool(a *app, name string) bool { for _, n := range toolNames(a) { if n == name { return true } } return false } // isOffSentinel 报告取值是否属于「显式关闭」的三个哨兵词(大小写/空白不敏感)。 func isOffSentinel(raw string) bool { switch strings.ToLower(strings.TrimSpace(raw)) { case "", "off", "none": return true default: return false } } // TestWireIOTools_DefaultEndpointRegistersWebSearch 是判据 ①:**不写这个键**时 // 默认值生效(本机兜底),web-search 出现在注册面里。 func TestWireIOTools_DefaultEndpointRegistersWebSearch(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) const want = "http://127.0.0.1:38471" if cfg.WebSearch.Endpoint != want { t.Fatalf("默认端点 = %q, want %q", cfg.WebSearch.Endpoint, want) } a := wsSurfaceApp(t, cfg, nil) if !wsHasTool(a, "web-search") { t.Fatalf("默认端点下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a)) } for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} { if !wsHasTool(a, name) { t.Errorf("其余 io 工具不得受影响,但 %q 不在注册面里", name) } } } // TestWireIOTools_UnusableEndpointOmitsWebSearch 是判据 ②④:端点显式关闭 // (空串 / off / none)或不是可用 http(s) 地址时,web-search **不进注册面** —— // 模型看不见它,且启动不报错(是「少一把工具」,不是「启动失败」)。同时必须留下 // 一条带键名的 WARN:配了却没用,绝不静默。 func TestWireIOTools_UnusableEndpointOmitsWebSearch(t *testing.T) { t.Parallel() cases := []struct { name string envLine string wantValue string }{ {"显式留空", "WEB_SEARCH_ENDPOINT=", ""}, {"off", "WEB_SEARCH_ENDPOINT=off", "off"}, {"大写 NONE", "WEB_SEARCH_ENDPOINT=NONE", "NONE"}, {"非 http scheme", "WEB_SEARCH_ENDPOINT=ftp://searx.example.com", "ftp://searx.example.com"}, {"缺 scheme", "WEB_SEARCH_ENDPOINT=127.0.0.1:38471", "127.0.0.1:38471"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() var buf bytes.Buffer cfg := wsLoadConfig(t, tc.envLine) if cfg.WebSearch.Endpoint != tc.wantValue { t.Fatalf("端点 = %q, want %q", cfg.WebSearch.Endpoint, tc.wantValue) } a := wsSurfaceApp(t, cfg, &buf) if wsHasTool(a, "web-search") { t.Fatalf("端点不可用时 web-search 不得进注册面,实际工具面 = %v", toolNames(a)) } // 其余工具照常(关掉的只是一件工具,不是整套 io 能力)。 for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} { if !wsHasTool(a, name) { t.Errorf("%q 不得因 web-search 关闭而消失", name) } } // WARN 必须点出是哪个键、为什么(不打印 URL 本身,避免 URL 里带凭据)。 out := buf.String() if !strings.Contains(out, webSearchEndpointEnv) { t.Fatalf("日志里没有点出 %s:%q", webSearchEndpointEnv, out) } if !strings.Contains(out, "web-search tool not registered") { t.Fatalf("日志里没有说明 web-search 未注册:%q", out) } // 关闭哨兵词本身会出现在原因文案里("空串 / off / none"),故只对 // 「像端点/主机」的那几个取值断言它没被打印。 if !isOffSentinel(tc.wantValue) && tc.wantValue != "" && strings.Contains(out, tc.wantValue) { t.Fatalf("日志里出现了端点值本身(URL 可能带凭据,禁止打印):%q", out) } }) } } // TestWireIOTools_EndpointIsPassedThrough 是判据 ③:断言的不是「没报错」,而是 // **配置里的端点真的被用了** —— 用一个可注入的 httptest 出口收请求,核对路径/查询串。 func TestWireIOTools_EndpointIsPassedThrough(t *testing.T) { t.Parallel() hits := make(chan *http.Request, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits <- r w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) })) defer srv.Close() cfg := wsLoadConfig(t, "WEB_SEARCH_ENDPOINT="+srv.URL+"/search") a := wsSurfaceApp(t, cfg, nil) tl, err := a.caps.Get("web-search") if err != nil { t.Fatalf("web-search 不在注册面里: %v", err) } toolValue, ok := tl.Value.(interface { Execute(context.Context, any) (any, error) }) if !ok { t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value) } if _, err := toolValue.Execute(context.Background(), map[string]any{"query": "昔涟"}); err != nil { t.Fatalf("Execute: %v", err) } select { case r := <-hits: if r.URL.Path != "/search" { t.Errorf("请求路径 = %q, want /search(配置里的端点必须原样使用)", r.URL.Path) } if got := r.URL.Query().Get("q"); got != "昔涟" { t.Errorf("q = %q", got) } if got := r.URL.Query().Get("format"); got != "json" { t.Errorf("format = %q, want json", got) } case <-time.After(2 * time.Second): t.Fatal("配置里的端点一个请求都没收到 —— 配置没被透传到工具") } } // TestWireIOTools_TimeoutIsWired 钉住 WEB_SEARCH_TIMEOUT 真的接到了工具上: // 一个慢端点 + 100ms 预算必须被掐断(默认 15s 够它跑完,因此「被掐断」只可能来自 // 这个配置)。负值按未配置处理并记一条 WARN。 func TestWireIOTools_TimeoutIsWired(t *testing.T) { t.Parallel() release := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case <-release: case <-r.Context().Done(): } _, _ = w.Write([]byte(`{"results":[]}`)) })) defer srv.Close() defer close(release) cfg := wsLoadConfig(t, "WEB_SEARCH_ENDPOINT="+srv.URL, "WEB_SEARCH_TIMEOUT=100ms", ) if cfg.WebSearch.Timeout != 100*time.Millisecond { t.Fatalf("WEB_SEARCH_TIMEOUT 没被解析: %v", cfg.WebSearch.Timeout) } a := wsSurfaceApp(t, cfg, nil) tl, err := a.caps.Get("web-search") if err != nil { t.Fatalf("web-search 不在注册面里: %v", err) } exec := tl.Value.(interface { Execute(context.Context, any) (any, error) }) if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err == nil { t.Fatal("100ms 预算下慢端点必须被掐断(说明配置没接到工具上)") } // 负值 = 笔误:按未配置处理(工具仍在),并留下一条带键名的 WARN。 var buf bytes.Buffer negCfg := wsLoadConfig(t, "WEB_SEARCH_ENDPOINT="+srv.URL, "WEB_SEARCH_TIMEOUT=-5s", ) negApp := wsSurfaceApp(t, negCfg, &buf) if !wsHasTool(negApp, "web-search") { t.Fatal("负超时只应退回默认,不该把工具从注册面摘掉") } if out := buf.String(); !strings.Contains(out, webSearchTimeoutEnv) { t.Fatalf("负超时必须在日志里点出 %s:%q", webSearchTimeoutEnv, out) } } // wsSearxngResultCount 用**裸 HTTP**问兜底服务(与工具实现无关)同一个查询,返回它此刻 // 真能产出的结果条数;不可达/应答不可解析时返回 -1。理由与命名同 io 侧的探针:上游 // 引擎被限流时兜底会「200 但 0 条」,那是外部降级,不该判成本仓的代码红。 func wsSearxngResultCount(endpoint, query string) int { client := &http.Client{Timeout: 25 * time.Second} resp, err := client.Get(endpoint + "/search?q=" + url.QueryEscape(query) + "&format=json") if err != nil { return -1 } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return -1 } var payload struct { Results []struct { URL string `json:"url"` } `json:"results"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return -1 } return len(payload.Results) } // TestWireIOTools_RealSearXNG_EndToEnd 是**真机**端到端:默认配置(一个 WEB_SEARCH_* // 键都不写)⇒ 组合根接线 ⇒ 注册面里的 web-search ⇒ 打本机兜底 SearXNG 真的拿到结果。 // 它只读外部服务,不碰后端进程、不碰仓库根产物。兜底没起、或其上游引擎被限流到产不出 // 结果时 **skip**(外部降级不判代码红;探针有结果而工具没有 = 真红)。 func TestWireIOTools_RealSearXNG_EndToEnd(t *testing.T) { t.Parallel() const query = "昔涟 崩坏星穹铁道" cfg := wsLoadConfig(t) upstream := wsSearxngResultCount(cfg.WebSearch.Endpoint, query) if upstream < 0 { t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", cfg.WebSearch.Endpoint) } if upstream == 0 { t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e") } a := wsSurfaceApp(t, cfg, nil) if !wsHasTool(a, "web-search") { t.Fatalf("默认配置下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a)) } tl, err := a.caps.Get("web-search") if err != nil { t.Fatalf("caps.Get(web-search): %v", err) } exec := tl.Value.(interface { Execute(context.Context, any) (any, error) }) got, err := exec.Execute(context.Background(), map[string]any{"query": query}) if err != nil { t.Fatalf("默认端点真机查询失败: %v", err) } m, ok := got.(map[string]any) if !ok { t.Fatalf("返回值类型 = %T", got) } count, _ := m["count"].(int) if count == 0 { t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m) } t.Logf("真机 e2e(默认配置 %s):返回 %d 条结果", cfg.WebSearch.Endpoint, count) } // TestWireIOTools_SeamsAndEnvTemplate 是防回归补充:组合根那两个接缝还在, // env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。 func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) { t.Parallel() seams := map[string][]string{ "wire_runtime.go": { // io 工具集必须经这一个入口构造,否则端点/后端裁决会被绕过。 "iotools.New(a.cfg, a.ioToolOptions()...)", }, "wire_io_tools.go": { // WS1-B:各家接线 + 主 provider/降级链选定,是 web-search 后端唯一来源。 "iotools.WithSearchProviders(specs...)", "iotools.WithSearchSelection(ws.Provider, ws.Fallback)", "iotools.WithSearchMaxResults(ws.MaxResults)", "iotools.WithoutSearch()", }, } for file, wants := range seams { src, err := os.ReadFile(file) if err != nil { t.Fatalf("read %s: %v", file, err) } for _, want := range wants { if !strings.Contains(string(src), want) { t.Fatalf("%s 里找不到 %q —— web-search 的接线/开关断了", file, want) } } } raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example")) if err != nil { t.Fatalf("read .env.example: %v", err) } template := string(raw) for _, line := range []string{ webSearchProviderEnv + "=", webSearchEndpointEnv + "=", webSearchTimeoutEnv + "=", webSearchMaxResultsEnv + "=", webSearchFallbackEnv + "=", // 默认值必须在模板里如实写出(本机兜底 + searxng 主后端),否则照模板部署的人 // 看不出它接的是什么。 webSearchEndpointEnv + "=http://127.0.0.1:38471", webSearchProviderEnv + "=searxng", } { if !strings.Contains(template, line) { t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", line) } } }