- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool 窄方法,保留运行期 Policy);下一轮即生效,不要求重启 - 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable - 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填) - 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷) - 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
212 lines
6.6 KiB
Go
212 lines
6.6 KiB
Go
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], `"`)
|
||
}
|