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