真机复现(我做的):同 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`(错误文案已写明处置);**未加"报错后自动清理重试"**。
399 lines
15 KiB
Go
399 lines
15 KiB
Go
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
|
||
})
|
||
}
|
||
}
|