package main // 本文件钉住切片 Z5 解封的那条接线(design/28 §1 M5 / §8 B5):把浏览器能力面的 // 「渲染取正文」适配成 web-fetch 的降级档,并在组合根决定**挂不挂**。 // // 三件事必须有测试拦(它们删掉之后运行期都没有症状): // // 1. 能力面里**没有**可用浏览器 ⇒ 这一档**不挂**,且如实留一条带来源的原因; // 2. 能力面里**有**可用浏览器 ⇒ 这一档真的被挂上、被调用(判据是浏览器那一档的 // 真实遭遇出现在返回体里,而不是「某个函数被调过」); // 3. 适配器在「取正文时已经没有可用浏览器」时如实报错,**不返回空串冒充成功**。 // // 另外两条是接线形状的钉子(照 `TestWireRuntime_CallsWireBrowser` 的先例):挂载点不能 // 从生产组装里消失,装配顺序不能把浏览器排在 io 工具集之后。 import ( "bytes" "context" "net/http" "net/http/httptest" "strings" "testing" browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" ) // webFetchToolName 是 web-fetch 在注册面里的名字(io 包里的字面量,这里按同一份契约引用)。 const webFetchToolName = "web-fetch" // serveSkeletonPage 起一个「直连能成功、但只有前端框架挂载点」的页面:它是这条降级档的 // 入口条件之一(直连成功却 `dynamicSuspect`)。 func serveSkeletonPage(t *testing.T) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(`
`)) })) t.Cleanup(srv.Close) return srv } // endpointHost 是白名单条目的写法:host:port(判定源会剥端口比对)。 func endpointHost(srv *httptest.Server) string { return strings.TrimPrefix(srv.URL, "http://") } // fallbackFetchTool 造一把**走挂载点**的 web-fetch:选项里带 `bodyFallbackOption()`, // 测试服务放进 SSRF 白名单(否则直连那一档先被守卫拒掉,降级档根本不会被触发)。 // // 选项走**生产路径**:`a.ioToolOptions()`(挂载点就在里面),而不是只把 `bodyFallbackOption()` // 单独拿出来 —— 这样两个用例证明的就是「启动期真正交出去的那份选项」。 func fallbackFetchTool(t *testing.T, a *app, srv *httptest.Server) sdk.Tool { t.Helper() opts := append(a.ioToolOptions(), iotools.WithAllowedHosts(endpointHost(srv)), iotools.WithHTTPClient(srv.Client())) set, err := iotools.New(a.cfg, opts...) if err != nil { t.Fatalf("iotools.New: %v", err) } for _, tool := range set { if tool.Name() == webFetchToolName { return tool } } t.Fatalf("工具集里没有 %s", webFetchToolName) return nil } // execFetch 跑一次 web-fetch 并把它返回的 map 取出来。 func execFetch(t *testing.T, tool sdk.Tool, rawURL string) map[string]any { t.Helper() got, err := tool.Execute(context.Background(), map[string]any{"url": rawURL}) if err != nil { t.Fatalf("web-fetch Execute: %v", err) } m, ok := got.(map[string]any) if !ok { t.Fatalf("web-fetch 返回的不是对象: %#v", got) } return m } // newWiredBrowserManager 造一个**报告可用**的浏览器管理器:探测结论说「本机有 Chrome」, // 但可执行文件故意不存在 —— `Available()` 为 true(于是降级档会被挂上),而任何一次真实 // 会话都会以 ErrLaunch 如实失败。 func newWiredBrowserManager(t *testing.T, cfg *config.Config, allowedHost string) *browsercap.Manager { t.Helper() mgr := browsercap.NewManager(browsercap.Options{ Availability: browsercfg.Availability{ Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe", Detail: "假来源(本用例不真起浏览器)", }, Config: browsercfg.Defaults(cfg.DataDir), DataDir: cfg.DataDir, AllowedHosts: []string{allowedHost}, }) t.Cleanup(func() { _ = mgr.Close() }) return mgr } // ① 没有可用浏览器 ⇒ 这一档不挂:直连的结论照实交出来,并如实说明「没有浏览器第二眼」; // 日志里留下分类原因码与来源标签。 func TestBodyFallback_NoBrowserMeansTierNotWired(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) var out bytes.Buffer a := browserTestApp(t, cfg, &out) // 注册面里没有 browser 工具 = 这台机器没有可用来源 srv := serveSkeletonPage(t) m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL) if m["fetchedBy"] != iotools.FetchedByDirect { t.Errorf("fetchedBy = %v, want %q(浏览器没取到,来源仍是直连)", m["fetchedBy"], iotools.FetchedByDirect) } if m["dynamicSuspect"] != true { t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"]) } be, _ := m["browserError"].(string) if !strings.Contains(be, "browser fallback disabled") { t.Errorf("这一档没挂时必须如实说明(browserError = %q)", be) } if !strings.Contains(out.String(), "reason="+browserFallbackReasonNoSource) { t.Errorf("日志里必须留下分类原因码 reason=%s(实得:%s)", browserFallbackReasonNoSource, out.String()) } if !strings.Contains(out.String(), "source=cmd/cyrene/wire_io_tools") { t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String()) } } // ② 有可用浏览器 ⇒ 这一档真的挂上并被调用:浏览器那一档的**真实遭遇**(这里是不存在的 // 可执行文件 ⇒ ErrLaunch)出现在返回体里,而不是被吞掉。 func TestBodyFallback_AvailableBrowserIsWiredAndHonest(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) var out bytes.Buffer a := browserTestApp(t, cfg, &out) srv := serveSkeletonPage(t) if err := a.caps.RegisterTool(browsertools.New(newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil { t.Fatalf("注册 browser 工具: %v", err) } m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL) be, _ := m["browserError"].(string) if !strings.Contains(be, "启动本机浏览器失败") { t.Errorf("浏览器这一档被挂上后就该被真调用,并把失败如实写进结果;browserError = %q", be) } if m["fetchedBy"] != iotools.FetchedByDirect { t.Errorf("fetchedBy = %v, want %q(浏览器没取到正文)", m["fetchedBy"], iotools.FetchedByDirect) } if !strings.Contains(out.String(), "web-fetch browser fallback wired") { t.Errorf("挂上时应有带来源的 INFO:%s", out.String()) } } // ③ 适配器在「取正文时已经没有可用浏览器」时如实报错,不返回空串冒充成功。 func TestBrowserBodyRenderer_NoUsableBrowserIsNotSilentSuccess(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) withUnavailable := registry.New() if err := withUnavailable.RegisterTool(browsertools.New( newUnavailableBrowserManager(t, cfg))); err != nil { t.Fatalf("注册 browser 工具: %v", err) } cases := []struct { name string caps *registry.Registry }{ {"注册面为 nil", nil}, {"注册面里没有 browser", registry.New()}, {"工具在但驱动不可用", withUnavailable}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := &browserBodyRenderer{caps: tc.caps} got, err := r.RenderBody(context.Background(), "https://example.com/") if err == nil { t.Fatalf("没有可用浏览器时必须报错(实得 %+v)", got) } if !strings.Contains(err.Error(), "no usable browser") { t.Errorf("err = %v,应说清是「这一档没有来源」", err) } if got.Text != "" || got.Source != "" { t.Errorf("失败时不该交出半个结论: %+v", got) } }) } } // newUnavailableBrowserManager 造一个「探测不到来源」的管理器(Available() == false)。 func newUnavailableBrowserManager(t *testing.T, cfg *config.Config) *browsercap.Manager { t.Helper() mgr := browsercap.NewManager(browsercap.Options{ Availability: browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"}, Config: browsercfg.Defaults(cfg.DataDir), DataDir: cfg.DataDir, }) t.Cleanup(func() { _ = mgr.Close() }) return mgr } // ④ 适配器跟的是**注册面里的当前那一把**,不是装配时的那一个:面板保存 browser 配置会 // 用 `ReplaceTool` 换掉工具与它背后的管理器,抓着旧指针会让这条降级档在一次保存之后变成 // 一具尸体(而且只在「取正文时」才暴露)。 func TestBrowserBodyRenderer_FollowsRegistryReplacement(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) var out bytes.Buffer a := browserTestApp(t, cfg, &out) if err := a.caps.RegisterTool(browsertools.New( newWiredBrowserManager(t, cfg, "example.com"))); err != nil { t.Fatalf("注册 browser 工具: %v", err) } r := &browserBodyRenderer{caps: a.caps} if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil || strings.Contains(err.Error(), "no usable browser") { t.Fatalf("替换前应当用**可用的**那一把(真去渲染,因假 exe 而失败);实得 %v", err) } // 面板保存 → 换掉注册面里的那一把(新的管理器不可用)。 if err := a.caps.ReplaceTool(browsertools.New(newUnavailableBrowserManager(t, cfg))); err != nil { t.Fatalf("替换 browser 工具: %v", err) } if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil || !strings.Contains(err.Error(), "no usable browser") { t.Fatalf("替换后适配器必须改口成「没有可用来源」,实得 %v", err) } } // ④ 挂载点不能从生产组装里消失:`ioToolOptions()` 是启动期唯一把降级档交给 io 包的地方, // 它被删掉之后编译照过、启动照起,只有模型那边少一次「浏览器第二眼」。 func TestIOToolOptions_IncludesBodyFallback(t *testing.T) { t.Parallel() src := readSource(t, "wire_io_tools.go") if !strings.Contains(src, "\t\ta.bodyFallbackOption(),") { t.Fatal("wire_io_tools.go 里找不到 `a.bodyFallbackOption()` —— 浏览器降级档不会被挂上" + "(J5 在 wire_body_fallback.go,挂载点必须留在生产组装里)") } } // ⑤ 装配顺序:浏览器必须在 io 工具集**之前**装配。降级档的挂载条件读的就是注册面, // 反过来写会让它永远读到「没有浏览器」⇒ 静默关死(没有任何运行期症状)。 func TestWireRuntime_BrowserWiredBeforeIoTools(t *testing.T) { t.Parallel() src := readSource(t, "wire_runtime.go") browse := strings.Index(src, "\ta.wireBrowser()") tools := strings.Index(src, "ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...") if browse < 0 || tools < 0 { t.Fatalf("wire_runtime.go 里找不到装配点(browser=%d, ioTools=%d)", browse, tools) } if browse > tools { t.Fatal("wire_runtime.go 把浏览器装配排在了 io 工具集之后 —— web-fetch 的浏览器降级档" + "会静默关死(挂载条件读的是注册面)") } }