package main // 本文件是切片 W3 那几组测试的**夹具**(`newReloadTestEnv` / 假上游 / 日志字段读取): // 单独成一个测试文件,是为了让三个主题文件(热重载、面板选择字段、解析口径与探针) // 各自都在 AGENTS §10 的文件行数之内,且共享的接线只写一份。 // // 夹具的关键差别(相对 wire_plugin_actions_test.go 的 actionTestEnv):它带 // `a.caps` / `a.dispatcher` —— 热重载换的就是注册表里的条目,没有能力面的夹具 // 根本证明不了这件事(那正是 L1/PLG-2 当时如实登记的缺口)。 import ( "bytes" "context" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "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" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // reloadTestEnv 是一个「组合根真的接好了能力面 + 面板动作面」的夹具。 type reloadTestEnv struct { app *app hub *pluginmgr.Panels disp *tool.Dispatcher path string out *bytes.Buffer } func newReloadTestEnv(t *testing.T, extra ...string) *reloadTestEnv { t.Helper() cfg := wsLoadConfig(t, extra...) b := bus.New() t.Cleanup(func() { _ = b.Close() }) mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog()) t.Cleanup(func() { mgr.Close(context.Background()) }) var out bytes.Buffer caps := registry.New() disp, err := tool.New(caps) if err != nil { t.Fatalf("tool.New: %v", err) } a := &app{ cfg: cfg, log: logger.New(logger.Options{Out: &out, Level: "debug"}), bus: b, plugins: mgr, caps: caps, dispatcher: disp, } // 启动期那条路(与 wsSurfaceApp 同源):ioToolOptions → iotools.New → 注册面。 set, err := iotools.New(cfg, a.ioToolOptions()...) if err != nil { t.Fatalf("iotools.New: %v", err) } for _, tl := range set { if rerr := disp.Register(tl); rerr != nil { t.Fatalf("register %s: %v", tl.Name(), rerr) } } wirePanelHub(a) if mgr.Panels() == nil { t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上") } return &reloadTestEnv{ app: a, hub: mgr.Panels(), disp: disp, path: runtimeconfig.WebSearchConfigPath(cfg.DataDir), out: &out, } } // save 走保存动作(表单形状 `{values:{...}}`),返回结果的**原始 any 与错误**。 func (e *reloadTestEnv) save(values map[string]any) (any, error) { return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: search.PanelPluginID, Action: search.ActionSaveProvider, Payload: map[string]any{"values": values}, }) } // mustSave 要求保存成功,并返回响应里的 `hotReload` 块(缺了就失败:它是本片新增的 // 「这次保存有没有热重载」的可机读事实)。 func (e *reloadTestEnv) mustSave(t *testing.T, values map[string]any) webSearchReloadOutcome { t.Helper() res, err := e.save(values) if err != nil { t.Fatalf("保存失败: %v", err) } m, ok := res.(map[string]any) if !ok { t.Fatalf("保存结果类型 = %T", res) } outcome, ok := m["hotReload"].(webSearchReloadOutcome) if !ok { t.Fatalf("响应里没有 hotReload(热重载没被接线): %#v", m) } return outcome } // search 走**分派器**(模型真正调工具的那条路)打一次 web-search,返回线格式结果。 func (e *reloadTestEnv) search(t *testing.T, query string) (map[string]any, error) { t.Helper() got, err := e.disp.Invoke(context.Background(), webSearchToolName, map[string]any{"query": query}) if err != nil { return nil, err } m, ok := got.(map[string]any) if !ok { t.Fatalf("工具返回值类型 = %T", got) } return m, nil } // toolValue 取注册面里那一把 web-search 的**当前**值(不在注册面里就失败)。 func (e *reloadTestEnv) toolValue(t *testing.T) any { t.Helper() cap, err := e.app.caps.Get(webSearchToolName) if err != nil { t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(e.app)) } return cap.Value } // rawFile 读可变源的**原始字节**(「字节级没变」是回滚判据里最强的那一种)。 func (e *reloadTestEnv) rawFile(t *testing.T) []byte { t.Helper() raw, err := os.ReadFile(e.path) if err != nil { t.Fatalf("read %s: %v", e.path, err) } return raw } // loadFile 读盘上的可变源。 func (e *reloadTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile { t.Helper() f, found, err := runtimeconfig.LoadWebSearch(e.path) if err != nil || !found { t.Fatalf("LoadWebSearch(%s): found=%v err=%v", e.path, found, err) } return f } // wsFakeUpstream 是一个假搜索上游:记下每一次命中,按预设状态码与响应体作答。 type wsFakeUpstream struct { url string hits chan *http.Request } func newWSFakeUpstream(t *testing.T, status int, body string) *wsFakeUpstream { t.Helper() hits := make(chan *http.Request, 8) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits <- r w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write([]byte(body)) })) t.Cleanup(srv.Close) return &wsFakeUpstream{url: srv.URL, hits: hits} } // newWSFakeSearxng 是一个「答一条结果」的 searxng 假上游(成功路径用)。 func newWSFakeSearxng(t *testing.T, title string) *wsFakeUpstream { t.Helper() return newWSFakeUpstream(t, http.StatusOK, `{"results":[{"title":"`+title+`","url":"http://x.example/1","content":"`+title+`"}]}`) } // drain 清掉已经排队的命中(用于「重载之后打的是不是新那家」的判定)。 func (f *wsFakeUpstream) drain() { for { select { case <-f.hits: default: return } } } // noHit 断言此刻没有新的命中(重载后**不该**再打到旧那家)。 func (f *wsFakeUpstream) noHit(t *testing.T) { t.Helper() select { case r := <-f.hits: t.Fatalf("旧上游又收到一个请求(%s)—— 重载没真的换掉工具", r.URL) default: } } // wsLogField 从启动日志里取一个字段值(slog TextHandler 的 `key=value`)。 func wsLogField(t *testing.T, out, key string) string { t.Helper() re := regexp.MustCompile(`(?:^|\s)` + regexp.QuoteMeta(key) + `=(\S+)`) m := re.FindStringSubmatch(out) if m == nil { t.Fatalf("启动日志里找不到 %s=…: %q", key, out) } return strings.Trim(m[1], `"`) }