Files
Cyrene_v2/cmd/cyrene/wire_body_fallback_test.go
AskaEth 93c5a4c46f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(browser): 启动失败要说出原因;预算耗尽 ≠ 浏览器坏了
真机现象:主人看到 Chrome 窗口内有横幅(他读作「不支持接受的参数」),日志只有
一句「等待调试端口时被取消: context deadline exceeded」,看不出是谁的错。

三件(本片):
- 接住子进程 stdout/stderr(有界:前 4 KiB / 上报 12 行,只缓存不输出)⇒ 失败时
  打一条带 exe / argv / chrome_output / chrome_output_bytes 的 WARN;成功只记字节数
  (不刷屏)。
- 三种启动失败在错误里分开:① 进程立刻退出(profile 被占用)② 等端口超时
  ③ 调用方预算耗尽(ctx 已结束 ⇒ **根本不 spawn**)。③ 与 ② 用两个截止时间比大小
  区分 —— 顺带修掉一个老缺陷:原来 ctx 一结束就报「等待调试端口时被取消」,而那
  几乎总是我们自己的 30s 用满,现被正确归到 ②。
- 降级档不再被**已耗尽**的轮预算连坐:如实报「预算耗尽 ⇒ 未尝试浏览器渲染,
  这不是浏览器故障」,**不许把"没试"说成"试了失败"**。plugins/tools/io/bodyfetch.go
  一字未动(fetchedBy / browserError 语义原样),只在**发起方**决定要不要发起。

对主人那条横幅的结论(有据,非推测):那是 Chrome 的 **bad-flags 警告栏**,触发者
是 `--disable-blink-features=AutomationControlled` —— 它明确在 Chromium 的 kBadFlags
名单里(bad_flags_prompt.cc 列了 kDisableBlinkFeatures/kEnableBlinkFeatures),本机
Chrome 154 zh-CN 的文案是「您使用的是不受支持的命令行标记:$1。稳定性和安全性会
有所下降。」;而「不支持接受的参数」在 Chrome 的 zh-CN/en-US 资源与二进制里**零命中**
⇒ 是意译。⚠️ 该参数是 design/28 §2 要求的(掩盖 navigator.webdriver)⇒ 横幅是
**设计取舍的视觉副作用**,去掉它才消失(但会暴露自动化特征)。登记待主人裁决。

验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;browser 与 cmd/cyrene
两包测试绿;我用 -overlay 去掉 cmd.Stderr 接线 ⇒ TestDial_ReportsChromeOutputAndArgvOnLaunchFailure
变红(chrome_output="" chrome_output_bytes=0)。

⚠️ 未部署:跑着的产物不含本片,要等下次 build + restart。
2026-10-07 10:37:33 +08:00

362 lines
15 KiB
Go
Raw Permalink 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
// 本文件钉住切片 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(`<html><body><div id="app"></div><script src="/x.js"></script></body></html>`))
}))
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)
}
}