Files
Cyrene_v2/internal/capability/browser/fakebrowser_test.go
T
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

144 lines
5.7 KiB
Go

package browser
// 本文件是**假浏览器**:启动路径的测试必须真起一个子进程,才能证明
// - 子进程的 stdout/stderr 被接住了(失败时上报、成功时不刷屏);
// - 三种失败情形(进程立刻退出 / 端口没出来 / 预算没了)分辨得开。
//
// 手法是 Go 里既有的「重跑自己」范式(stdlib `os/exec` 的 testhelper 同款,本仓
// `internal/capability/plugin/manager_test.go` 的 MCP stdio 假 server 也用它):
// 被当成浏览器拉起的可执行文件就是**测试二进制自己**(`os.Args[0]`),行为由 profile
// 目录里的一份脚本文件给出 —— 脚本落在 `Dial` 传 `--user-data-dir` 的那个目录里,
// 因此每个用例各说各话、可以并行。
//
// `TestMain` 是接住它的唯一位置:子进程的 argv 是**浏览器参数**(没有 `-test.run`),
// 到不了任何 Test 函数;不接住就会递归跑整个测试套件。
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// fakeBrowserScriptFile 是假浏览器脚本的文件名(落在 profile 目录里)。
fakeBrowserScriptFile = "fake-browser.json"
// fakeBrowserUserDataDirFlag 是脚本的定位方式:profile 目录就是 argv 里
// `--user-data-dir=` 的值(真浏览器的同一个参数)。
fakeBrowserUserDataDirFlag = "--user-data-dir="
// fakeBrowserNoScriptExit 是「argv 像浏览器、但没有脚本」时的退出码:宁可表现得像
// 一个立刻退出的浏览器,也不递归跑测试套件(那是测试基建事故,不是用例失败)。
fakeBrowserNoScriptExit = 7
)
// fakeBrowserScript 是一次「假启动」的行为脚本(字段零值 = 不做那件事)。
type fakeBrowserScript struct {
// Stdout / Stderr 是要写出去的若干行(模拟浏览器自己说的话)。
Stdout []string `json:"stdout"`
Stderr []string `json:"stderr"`
// ExitCode 是退出码。
ExitCode int `json:"exitCode"`
// WritePort > 0 ⇒ 往 profile 目录写 DevToolsActivePort(模拟真浏览器就绪);
// Dial 只读这个文件,不会去连端口,所以端口号不必真的有人在听。
WritePort int `json:"writePort"`
// SleepMS > 0 ⇒ 之后继续活着(模拟浏览器常驻):由 Dial 的停止函数或超时路径 Kill。
SleepMS int `json:"sleepMs"`
}
// TestMain 让测试二进制可以**兼任**假浏览器。
func TestMain(m *testing.M) {
if dir, ok := fakeBrowserProfileDir(os.Args[1:]); ok {
os.Exit(runFakeBrowser(dir))
}
os.Exit(m.Run())
}
// fakeBrowserProfileDir 从 argv 里取出 profile 目录(找不到 ⇒ 这是普通的测试运行)。
func fakeBrowserProfileDir(args []string) (string, bool) {
for _, a := range args {
if dir, ok := strings.CutPrefix(a, fakeBrowserUserDataDirFlag); ok {
return dir, true
}
}
return "", false
}
// runFakeBrowser 按脚本演一次假浏览器,返回退出码。
func runFakeBrowser(profileDir string) int {
raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserScriptFile))
if err != nil {
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
return fakeBrowserNoScriptExit
}
var script fakeBrowserScript
if err := json.Unmarshal(raw, &script); err != nil {
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
return fakeBrowserNoScriptExit
}
for _, line := range script.Stdout {
fmt.Fprintln(os.Stdout, line)
}
for _, line := range script.Stderr {
fmt.Fprintln(os.Stderr, line)
}
if script.WritePort > 0 {
content := strconv.Itoa(script.WritePort) + "\nfake-browser-guid\n"
if err := os.WriteFile(filepath.Join(profileDir, devToolsPortFile), []byte(content), 0o600); err != nil {
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
return fakeBrowserNoScriptExit
}
}
if script.SleepMS > 0 {
time.Sleep(time.Duration(script.SleepMS) * time.Millisecond)
}
return script.ExitCode
}
// newFakeChrome 造一个「可执行文件 = 测试二进制自己」的 localChrome,并把脚本写进
// profile 目录(`Dial` 会把它当 `--user-data-dir` 传下来)。
func newFakeChrome(t *testing.T, script fakeBrowserScript, timeout time.Duration, log *slog.Logger) *localChrome {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatalf("取测试二进制路径: %v", err)
}
dir := t.TempDir()
raw, err := json.Marshal(script)
if err != nil {
t.Fatalf("编码假浏览器脚本: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, fakeBrowserScriptFile), raw, 0o600); err != nil {
t.Fatalf("写假浏览器脚本: %v", err)
}
return &localChrome{
exe: exe, profileDir: dir,
viewportW: 1280, viewportH: 800,
timeout: timeout, log: log,
}
}
// newCapturedLog 造一个把日志收进缓冲的 logger(带来源标签,与 discardLog 同源)。
func newCapturedLog(level slog.Level) (*slog.Logger, *bytes.Buffer) {
var buf bytes.Buffer
base := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: level}))
return logger.WithSource(base, logSource), &buf
}
// escapedInLog 把字符串按 slog TextHandler 的转义规则处理:值里含空格/引号时它整段走
// `strconv.Quote`(反斜杠变双反斜杠);不含时原样输出。断言前把两种形态都认,免得
// 「同一个路径在 exe= 里是单反斜杠、在 argv= 里是双反斜杠」这种细节把用例判红。
func escapedInLog(s string) string { return strings.ReplaceAll(s, `\`, `\\`) }
// containsInLog 报告日志里是否出现了这段文字(原样或 slog 的引号化形态)。
func containsInLog(logs, want string) bool {
return strings.Contains(logs, want) || strings.Contains(logs, escapedInLog(want))
}