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/internal/logger" "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 的浏览器降级档" + "会静默关死(挂载条件读的是注册面)") } } // ⑥ 预算口径:这一轮的预算已经耗尽(ctx 已结束)时,这一档**不发起**,如实报 // 「预算耗尽 ⇒ 未尝试浏览器渲染」。判据是两句话都要在,而那句误导性的 // 「启动本机浏览器失败」绝不能出现 —— 假 exe 一定启动失败,它出现就证明我们真去起了 // 浏览器(那正是真机踩的坑:窗口上跳出浏览器的抱怨,日志里像浏览器坏了)。 func TestBrowserBodyRenderer_ExhaustedBudgetIsNotABrowserFailure(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, log: logger.WithSource(a.log, ioLogSource)} cases := []struct { name string ctx func() (context.Context, context.CancelFunc) }{ {"已取消", func() (context.Context, context.CancelFunc) { ctx, cancel := context.WithCancel(context.Background()) cancel() return ctx, cancel }}, {"已到期", func() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 0) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctx, cancel := tc.ctx() defer cancel() got, err := r.RenderBody(ctx, "https://example.com/") if err == nil { t.Fatalf("预算耗尽时必须报错(不许拿空正文冒充成功): %+v", got) } t.Logf("实测 err=%v", err) msg := err.Error() if !strings.Contains(msg, browserFallbackReasonBudget) { t.Errorf("err = %v,应带原因码 %q", err, browserFallbackReasonBudget) } if !strings.Contains(msg, "未尝试浏览器渲染") { t.Errorf("err = %v,应说清「没有试过」", err) } if strings.Contains(msg, "启动本机浏览器失败") { t.Errorf("预算耗尽不许被报成浏览器故障: %v", err) } if strings.Contains(msg, "no usable browser") { t.Errorf("「预算耗尽」与「没有可用来源」是两件事,不许混成一句: %v", err) } if got.Text != "" || got.Source != "" { t.Errorf("失败时不该交出半个结论: %+v", got) } if !strings.Contains(out.String(), "reason="+browserFallbackReasonBudget) { t.Errorf("日志里必须留下原因码(实得:%s)", out.String()) } if !strings.Contains(out.String(), "source="+ioLogSource) { t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String()) } }) } } // ⑦ 端到端(走生产接线的选项):预算耗尽的那一轮,web-fetch 的错误里**两条档的真实遭遇 // 都要在** —— 直连为什么没成 + 浏览器这一档为什么**没试**(预算耗尽)。io 层的 // `fetchedBy` / `browserError` 语义不许变(那是它的红线),所以这里断言的是它们的**输出** // 仍然照实:两条失败各自出现,谁也别替谁背锅。 func TestBodyFallback_ExhaustedBudgetIsHonestEndToEnd(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) } tool := fallbackFetchTool(t, a, srv) ctx, cancel := context.WithCancel(context.Background()) cancel() got, err := tool.Execute(ctx, map[string]any{"url": srv.URL}) if err == nil { t.Fatalf("两条档都没成时必须报错(实得 %#v)", got) } t.Logf("实测 web-fetch 错误:%v", err) msg := err.Error() if !strings.Contains(msg, "browser fallback failed too") { t.Errorf("io 层「两条失败都照实报」的口径不许变: %v", err) } if !strings.Contains(msg, browserFallbackReasonBudget) { t.Errorf("浏览器那一档要如实说「预算耗尽 ⇒ 未尝试」: %v", err) } if strings.Contains(msg, "启动本机浏览器失败") { t.Errorf("预算耗尽不许被说成浏览器故障(真去起了浏览器): %v", err) } if got != nil { t.Errorf("失败时不该交出结果: %#v", got) } }