Files
Cyrene_v2/cmd/cyrene/websearch_testenv_test.go
T
AskaEth a30f4a49ff feat(search): provider 热重载 + primary/fallback(W3)
- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
2026-10-06 15:59:12 +08:00

212 lines
6.6 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
// 本文件是切片 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], `"`)
}