Files
Cyrene_v2/internal/capability/browser/launch_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

394 lines
15 KiB
Go
Raw 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 browser
import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestLaunchArgs_HeadfulCarriesNoAutomationTraces 是本片**最重要的一条测试**:它把
// `DevDocs/reference/browser-stealth/10 §2.1` 的黑名单钉成可执行判据 ——
// 有头路径**不许**出现任何 `--headless*`,也不许出现「自动化味道」的参数
// (`--enable-automation` 以及 Playwright/Puppeteer 默认传的那一串)。
// 有人往清单里加回一条,这里立刻红。
func TestLaunchArgs_HeadfulCarriesNoAutomationTraces(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: `C:\tmp\p`, headless: false, viewportW: 1280, viewportH: 800}
args := l.launchArgs()
forbidden := []string{
"--headless",
"--enable-automation",
"--disable-extensions",
"--disable-default-apps",
"--disable-component-extensions-with-background-pages",
"--disable-component-update",
"--disable-popup-blocking",
}
for _, bad := range forbidden {
for _, a := range args {
if strings.HasPrefix(a, bad) {
t.Errorf("有头启动参数里出现了不许传的 %q:%v", bad, args)
}
}
}
}
// TestLaunchArgs_RequiredFlagsPresent 钉住「必须传」的那几条:profile、随机回环调试
// 端口、窗口尺寸、语言、免首次运行、以及 AutomationControlled 兜底防线。
func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/prof", headless: false, viewportW: 1024, viewportH: 768}
args := l.launchArgs()
for _, want := range []string{
"--user-data-dir=/tmp/prof",
"--remote-debugging-port=0",
"--window-size=1024,768",
"--lang=zh-CN",
"--no-first-run",
"--no-default-browser-check",
"--disable-blink-features=AutomationControlled",
"--remote-allow-origins=*",
"about:blank",
} {
if !containsArg(args, want) {
t.Errorf("启动参数缺少 %q:%v", want, args)
}
}
}
// TestLaunchArgs_HeadlessOnlyWhenConfigured 钉住「无头只在显式配置时出现」:
// 默认档是有头(design/28 §2),无头是面板显式降级的结果。
func TestLaunchArgs_HeadlessOnlyWhenConfigured(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/p", headless: true, viewportW: 800, viewportH: 600}
args := l.launchArgs()
if len(args) == 0 || args[0] != "--headless=new" {
t.Errorf("无头档的第一个参数应为 --headless=new,实得:%v", args)
}
if !containsArg(args, "--disable-blink-features=AutomationControlled") {
t.Error("无头档也必须保留 AutomationControlled 防线")
}
}
// TestReadDevToolsPort 钉住端口文件解析:正常两行、只有一行、缺文件、垃圾内容。
func TestReadDevToolsPort(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, devToolsPortFile)
if _, ok := readDevToolsPort(path); ok {
t.Error("文件不存在时不该报 ok")
}
if err := os.WriteFile(path, []byte("54321\nbrowser-guid-xyz\n"), 0o600); err != nil {
t.Fatal(err)
}
if port, ok := readDevToolsPort(path); !ok || port != 54321 {
t.Errorf("读到 (%d,%v), want (54321,true)", port, ok)
}
if err := os.WriteFile(path, []byte("not-a-port\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readDevToolsPort(path); ok {
t.Error("垃圾内容不该报 ok")
}
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readDevToolsPort(path); ok {
t.Error("端口 0 不该报 ok")
}
}
// TestLocalChromeDescribe 钉住来源描述如实反映有头/无头(日志与工具结果都用它)。
func TestLocalChromeDescribe(t *testing.T) {
t.Parallel()
headful := &localChrome{exe: "chrome.exe"}
if got := headful.Describe(); !strings.Contains(got, "headful") {
t.Errorf("Describe() = %q,应含 headful", got)
}
headless := &localChrome{exe: "chrome.exe", headless: true}
if got := headless.Describe(); !strings.Contains(got, "headless") {
t.Errorf("Describe() = %q,应含 headless", got)
}
}
// TestLocalChromeAdoptsLiveEndpoint 钉住「认领已在跑的实例」:profile 目录里的
// DevToolsActivePort 还活着 ⇒ Dial 直接交出那个端点、**不启动任何进程**。
// 这不是优化:同一 profile 起第二个实例时 Chromium 会委托给已在跑的那个然后自己退出,
// 新进程永远不写端口文件 ⇒ 不做认领,功能会被一个残留实例卡死。
func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/json/version" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0"}`))
}))
defer srv.Close()
port := strings.TrimPrefix(srv.URL, "http://127.0.0.1:")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte(port+"\nGUID\n"), 0o600); err != nil {
t.Fatal(err)
}
l := &localChrome{exe: "definitely-not-a-browser.exe", profileDir: dir, timeout: time.Second, log: discardLog()}
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("应当认领活着端点,实得: %v", err)
}
if endpoint != "http://127.0.0.1:"+port {
t.Errorf("endpoint = %q", endpoint)
}
stop() // 认领来的实例不归我们杀:停止函数必须是空操作(调用不 panic 即通过)
}
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口文件在、但没人听 ⇒ **不认领**
// (否则会连到一个死端口,比启动失败更难排查)。
func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte("1\nGUID\n"), 0o600); err != nil {
t.Fatal(err)
}
l := &localChrome{exe: "x", profileDir: dir, timeout: time.Second, log: discardLog()}
if endpoint, ok := l.adoptExisting(context.Background()); ok {
t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint)
}
}
// TestWaitDevToolsPort_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
// 最可能的原因写进错误(profile 被占用)—— 而不是把超时白等完、只留一句「超时」。
func TestWaitDevToolsPort_ReportsEarlyExit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
l := &localChrome{profileDir: dir, timeout: 10 * time.Second, log: discardLog()}
exited := make(chan struct{})
close(exited)
start := time.Now()
_, err := l.waitDevToolsPort(context.Background(), exited)
if err == nil {
t.Fatal("进程退出后应当报错")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("等了 %v 才报错,应当立刻返回", elapsed)
}
if !strings.Contains(err.Error(), "profile") {
t.Errorf("错误里应点明 profile 占用这个最常见原因: %v", err)
}
}
// containsArg 报告参数表里是否有这一条(逐字相等)。
func containsArg(args []string, want string) bool {
for _, a := range args {
if a == want {
return true
}
}
return false
}
// ── 启动失败的可诊断性与分类(假浏览器见 fakebrowser_test.go) ──────────────────────
// TestDial_ReportsChromeOutputAndArgvOnLaunchFailure 是本片最重要的一条:启动失败时
// **必须**把浏览器自己说的话与本次 argv 一起报进日志。真机踩过的坑正是这条的反面 ——
// 窗口上跳出浏览器的抱怨,我们的日志里却只有自己这一侧的猜测("等待调试端口时被取消"),
// 于是「浏览器为什么拒绝」永远查不到。
func TestDial_ReportsChromeOutputAndArgvOnLaunchFailure(t *testing.T) {
t.Parallel()
const complaint = "不支持的标记: --remote-allow-origins=*"
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{complaint}, ExitCode: 2}, 5*time.Second, log)
_, stop, err := l.Dial(context.Background())
if stop != nil {
t.Error("失败路径不该交出停止函数")
}
if err == nil {
t.Fatal("假浏览器立刻退出时 Dial 必须报错")
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("错误必须仍然包住 ErrLaunch(errors.Is 口径不变): %v", err)
}
if !strings.Contains(err.Error(), launchMarkerProcessExited) {
t.Errorf("错误里应点明「进程立刻退出」这一类: %v", err)
}
logs := buf.String()
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
for _, want := range []string{
complaint, // 浏览器自己说的话
l.exe, // 是哪个可执行文件
"--user-data-dir=" + l.profileDir, // 本次 argv(profile 路径不含凭据)
"--remote-debugging-port=0",
"chrome_output_bytes=",
} {
if !containsInLog(logs, want) {
t.Errorf("失败日志里缺少 %q:\n%s", want, logs)
}
}
}
// TestDial_SuccessDoesNotSpamChromeOutput 钉住反面:**成功时不刷屏**。正常启动时
// Chrome 也会往 stderr 写常规信息,那些不该进日志;只留一个字节数(Debug,供排查用)。
func TestDial_SuccessDoesNotSpamChromeOutput(t *testing.T) {
t.Parallel()
const chatter = "DevTools listening on ws://127.0.0.1:55543"
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{chatter}, WritePort: 55543, SleepMS: 5000,
}, 5*time.Second, log)
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("脚本写了端口文件,Dial 应当成功: %v", err)
}
defer stop()
if endpoint != "http://127.0.0.1:55543" {
t.Errorf("endpoint = %q", endpoint)
}
logs := buf.String()
if containsInLog(logs, chatter) {
t.Errorf("成功启动时不该把浏览器自己的输出打进日志(刷屏):\n%s", logs)
}
if !strings.Contains(logs, "browser launched") || !strings.Contains(logs, "chrome_output_bytes=") {
t.Errorf("成功时只应留一条只报规模(字节数)的 Debug:\n%s", logs)
}
}
// TestDial_ExhaustedBudgetDoesNotSpawnBrowser 钉住预算口径的**起点**:ctx 已经结束时
// **不起进程** —— 起了也只是生一个会被马上杀掉的窗口,并把「没时间了」伪装成
// 「浏览器起不来」。
func TestDial_ExhaustedBudgetDoesNotSpawnBrowser(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{"这一行不该出现:进程根本没被拉起"}, WritePort: 55543, SleepMS: 5000,
}, 5*time.Second, log)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("预算已耗尽时 Dial 必须报错")
}
t.Logf("实测错误(ctx 已取消):%v", err)
if !errors.Is(err, ErrLaunch) {
t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerBudget) {
t.Errorf("错误必须读成「预算耗尽」,实得: %v", err)
}
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
t.Errorf("预算耗尽不许被说成「端口等超时」(那是浏览器侧的问题): %v", err)
}
if _, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile)); ok {
t.Error("预算耗尽时不该起浏览器(脚本被执行了)")
}
if strings.Contains(buf.String(), "这一行不该出现") {
t.Errorf("预算耗尽时子进程不该存在:\n%s", buf.String())
}
}
// TestDial_CallerDeadlineIsReportedAsBudget 钉住**真机那条路**:等待期间调用方的截止
// 时间到了(这一轮的工具预算烧完),错误必须读成「预算耗尽、浏览器没坏」,而不是
// 「等待调试端口时被取消」这种既像浏览器故障、又说不清谁取消的文案。
func TestDial_CallerDeadlineIsReportedAsBudget(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{"浏览器在等,但没人给它时间"}, SleepMS: 5000}, 10*time.Second, log)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("调用方截止时间到之后 Dial 必须报错")
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerBudget) {
t.Errorf("错误必须读成「预算耗尽」,实得: %v", err)
}
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
t.Errorf("调用方预算先到时不许报成我们自己的端口等待超时: %v", err)
}
if !containsInLog(buf.String(), l.profileDir) {
t.Errorf("这条失败也要带 argv(否则现场没法复现):\n%s", buf.String())
}
}
// TestDial_ThreeLaunchFailuresAreDistinguishable 钉住「一眼分辨」:三条出口 ——
// ① 进程立刻退出 / ② 我们自己的端口等待超时 / ③ 调用方预算耗尽 —— 各自的错误文案里
// 必须带**互不相同**的标记,谁也别冒充谁。
func TestDial_ThreeLaunchFailuresAreDistinguishable(t *testing.T) {
t.Parallel()
cases := []struct {
name string
script fakeBrowserScript
timeout time.Duration
ctx func() (context.Context, context.CancelFunc)
wantMarker string
forbid string
}{
{
name: "①进程立刻退出",
script: fakeBrowserScript{Stderr: []string{"参数被拒,进程退出"}, ExitCode: 1},
timeout: 5 * time.Second,
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
wantMarker: launchMarkerProcessExited,
forbid: launchMarkerBudget,
},
{
name: "②等端口超时",
script: fakeBrowserScript{Stderr: []string{"起来了但从不写端口文件"}, SleepMS: 5000},
timeout: 300 * time.Millisecond,
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
wantMarker: launchMarkerPortTimeout,
forbid: launchMarkerBudget,
},
{
name: "③调用方预算耗尽",
script: fakeBrowserScript{Stderr: []string{"还没就绪预算就没了"}, SleepMS: 5000},
timeout: 10 * time.Second,
ctx: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 200*time.Millisecond)
},
wantMarker: launchMarkerBudget,
forbid: launchMarkerPortTimeout,
},
}
seen := map[string]string{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l := newFakeChrome(t, tc.script, tc.timeout, discardLog())
ctx, cancel := tc.ctx()
defer cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("这一档必须报错")
}
msg := err.Error()
t.Logf("%s 实测错误:%v", tc.name, err)
if !strings.Contains(msg, tc.wantMarker) {
t.Errorf("错误里应带标记 %q:%v", tc.wantMarker, err)
}
if strings.Contains(msg, tc.forbid) {
t.Errorf("错误里不该出现 %q(冒充了另一类失败):%v", tc.forbid, err)
}
if prev, dup := seen[tc.wantMarker]; dup {
t.Errorf("标记 %q 与 %q 撞了 —— 三种情形必须可分", tc.wantMarker, prev)
}
seen[tc.wantMarker] = tc.name
})
}
}