真机现象:主人看到 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。
362 lines
15 KiB
Go
362 lines
15 KiB
Go
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)
|
||
}
|
||
}
|