Files
Cyrene_v2/internal/capability/browser/launch_test.go
T
AskaEth 7f63c5bec4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(browser): 同 profile 的浏览器还活着时不再必然启动超时
真机复现(我做的):同 profile 再起一次 chrome.exe ⇒ Chrome **把命令委托给已有
实例并 exit 0**,stderr 逐字「正在现有的浏览器会话中打开。」⇒ **不产生新的
DevToolsActivePort** ⇒ 我们的等待只能超时 ⇒ 浏览器降级档整体不可用。

修法:启动前加一跳**探测**,三档各自出路 ——
- 端口记录缺席 / 端口没人听 ⇒ 正常启动(后者先把**陈记录**清掉:那是启动时写下
  的记录、不是锁,删一条指向死端口的记录不会伤到任何活实例);
- 端口在听 + `/json/version` 有合法响应 ⇒ **复用它**(INFO `browser debug
  endpoint reused`),**不再起第二个**;
- 端口在听但握不上手 ⇒ **如实报错**(保留现场,既不起进程也不删记录);
- 真被占用且拿不到端点 ⇒ 报错带 **Chrome 原话**(`withChromeOutput`)+ 可操作处置。

🔴 红线(写进错误文案与注释):**绝不杀别人的浏览器**、不发信号抢 profile、
不新开监听端口;`ErrLaunch` / `errors.Is` 口径未破。

验收(主会话复跑 + 亲自变异):browser 包与 plugins/tools/io 两包 build/vet/test
全绿;我用 -overlay 摘掉「复用」那一支 ⇒ TestLocalChromeAdoptsLiveEndpoint 与
TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 变红。子代理另跑了真机
端到端(临时 profile):会话 1 拿端口 51732 → 裸起第二次 chrome(exit 0)→
会话 2 **复用同一端口、未起第二个实例**。

⚠️ 已知未修(如实):Chrome 那句委托提示是**控制台代码页(GBK)字节**,我们的
UTF-8 日志里会显示成乱码(字节保真、人看不了);彻底解决要引编码转换,另定。
⚠️ 判据③的取舍:陈记录 +端口恰被别的程序复用时,会持续报错直到人工删
`DevToolsActivePort`(错误文案已写明处置);**未加"报错后自动清理重试"**。
2026-10-07 10:56:16 +08:00

399 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 钉住反面:端口文件在、但没人听 ⇒ **不认领**
// (否则会连到一个死端口,比启动失败更难排查)。它同时钉住「没人听」的**分类**:
// 探测要给出 ("", nil) 而不是错误 —— 这是「陈文件,可以清掉后照常启动」的那一条出路。
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()}
endpoint, err := l.probeExistingEndpoint(context.Background())
if endpoint != "" {
t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint)
}
if err != nil {
t.Fatalf("死端口是「陈文件」而不是「坏端点」,探测不该报错: %v", err)
}
}
// 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
})
}
}