Files
Cyrene_v2/internal/capability/browser/launch_test.go
T
AskaEth 6fe68ecfac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 方案 A 落地 —— 显式端口 + 去掉 stealth flag(主人 2026-10-07 拍板)
依据 `DevDocs/reference/chromium-cdp/`(9 份实测+官方源码):旧姿势传 `--remote-debugging-port=0`
会被 Chromium 当成 ChromeDriver 式自动化启动 ⇒ 主动打开 `AutomationControlled`
⇒ `navigator.webdriver=true`;那个 bad-flag 是承重覆盖,但代价是**每次有头弹横幅**。
改用显式端口后 `webdriver` **本来就是 false** ⇒ flag 变纯冗余 ⇒ 去掉它,**两者兼得**。

- `launch.go`:`--remote-debugging-port=<我们选的端口>` 替掉 `=0`;**删掉**
  `--disable-blink-features=AutomationControlled`(注释写明理由)。
- 新增 `portrecord.go`:端口来源与落点的**唯一出处** —— 取空闲端口(`net.Listen`
  取号后立刻关闭,**本进程不常驻监听**)、写/读/清**我们自己的记录**
  `<profile>/cyrene-debug-port`(选它与 Chrome 的 `DevToolsActivePort` 同目录:
  天然按 profile 隔离、profile 换掉记录自动消失、内容只有端口号不含凭据);
  **兼容老记录**(换二进制时上一版起的浏览器可能还在跑 ⇒ 认不出会把"自己的浏览器"
  误报成"profile 被别人占了");就绪判据换成「我们选的那个端口答得上 `/json/version`」;
  TOCTOU 处理 = 端口被占**换号重试一次**,重试仍失败则如实报错且不留记录。
- 三条认领路径(复用/清陈记录/如实报错保留现场)与三条失败分类(进程立刻退出 /
  等端口超时 / 预算耗尽)**全部保留**;`ErrLaunch` + `errors.Is` 口径未变;
  **绝不杀进程**红线未变。

真机自证(子代理跑,默认 Skip 的 `realchrome_test.go`,`CYRENE_BROWSER_CDP_E2E=1` 开闸):
`webdriver=false` · 命令行**无** flag/`--enable-automation`/`--headless` · UA 无
`HeadlessChrome`、`plugins=5`、`languages=zh-CN,zh`、`window.chrome` 在 ·
**UI Automation 控件树 17 个命名节点里没有「信息栏容器/信息栏」⇒ 无横幅** ·
第二次 Dial **复用同端口、浏览器主进程仍是同一个 PID** · 收尾按 profile 路径筛残留 = 0。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + 两包 vet 绿;browser 包测试绿;
我用 -overlay 把该 flag 加回参数表 ⇒ `TestLaunchArgs_HeadfulCarriesNoAutomationTraces`
变红(逐字报"不许传的 flag")。

⚠️ 未验(如实):无头档的横幅(无窗口判不了)、`chrome-headless-shell`、风控侧是否把
"端口写法"本身当信号、生产 profile 整链路(未碰)。
2026-10-07 11:18:25 +08:00

449 lines
19 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 默认传的那一串),
// 更不许出现 `--disable-blink-features` / `--enable-blink-features`:这两条在官方
// `kBadFlags` 名单里(reference/chromium-cdp/01),而改用**显式调试端口**后
// `navigator.webdriver` 本来就是 false(同册 07 的 g7/g8),那条 flag 已成空操作 ——
// 留着只剩「每次有头启动弹一条不受支持的命令行标记」这一个副作用。
// 有人往清单里加回一条,这里立刻红。
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(9411)
forbidden := []string{
"--headless",
"--enable-automation",
"--disable-blink-features",
"--enable-blink-features",
"--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、**显式**调试端口、
// 窗口尺寸、语言、免首次运行。显式端口是本片的核心改动:`--remote-debugging-port=0` 会被
// Chromium 当作 ChromeDriver 式自动化启动、主动点亮 `navigator.webdriver`
// (reference/chromium-cdp/02 §4.2、07 的 g1 vs g7),因此这条用例**同时**钉住
// 「端口必须是显式数字」与「不许出现 =0」。
func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) {
t.Parallel()
const port = 9411
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/prof", headless: false, viewportW: 1024, viewportH: 768}
args := l.launchArgs(port)
for _, want := range []string{
"--user-data-dir=/tmp/prof",
"--remote-debugging-port=9411",
"--window-size=1024,768",
"--lang=zh-CN",
"--no-first-run",
"--no-default-browser-check",
"--remote-allow-origins=*",
"about:blank",
} {
if !containsArg(args, want) {
t.Errorf("启动参数缺少 %q:%v", want, args)
}
}
for _, a := range args {
if strings.HasPrefix(a, "--remote-debugging-port=0") {
t.Errorf("调试端口必须是显式端口(=0 会被 Chromium 当作自动化启动):%v", args)
}
}
}
// TestLaunchArgs_HeadlessOnlyWhenConfigured 钉住「无头只在显式配置时出现」:
// 默认档是有头(design/28 §2),无头是面板显式降级的结果。
//
// ⚠️ 无头档**必然**点亮 `AutomationControlled`(`--headless` 本身就是四条打开路径之一,
// reference/chromium-cdp/02 §4.1),这是官方行为、不是我们的参数能去掉的:本片只保证
// **有头**档(默认档)不再靠那条 bad flag 去抵消它。无头档的 `webdriver` 读数**未实测**
// (见本片报告「没能证明的」)。
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(9412)
if len(args) == 0 || args[0] != "--headless=new" {
t.Errorf("无头档的第一个参数应为 --headless=new,实得:%v", args)
}
if !containsArg(args, "--remote-debugging-port=9412") {
t.Errorf("无头档也必须用显式调试端口:%v", args)
}
}
// TestReadPortNumberFile 钉住「首行是端口号」的解析:我们自己的记录(一行端口)与
// Chromium 的 `DevToolsActivePort`(首行端口、次行 browser_guid)共用同一个解析。
func TestReadPortNumberFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, portRecordFile)
if _, ok := readPortNumberFile(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 := readPortNumberFile(path); !ok || port != 54321 {
t.Errorf("读到 (%d,%v), want (54321,true)", port, ok)
}
if err := os.WriteFile(path, []byte("54322\n"), 0o600); err != nil {
t.Fatal(err)
}
if port, ok := readPortNumberFile(path); !ok || port != 54322 {
t.Errorf("我们自己的一行记录应可解析,实得 (%d,%v)", port, ok)
}
if err := os.WriteFile(path, []byte("not-a-port\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readPortNumberFile(path); ok {
t.Error("垃圾内容不该报 ok")
}
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readPortNumberFile(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 目录里我们自己记的
// 调试端点(`cyrene-debug-port`)还活着 ⇒ 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()
writePortRecordFile(t, dir, portOfEndpoint(t, srv.URL))
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()
writePortRecordFile(t, dir, 1)
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)
}
}
// TestWaitDebugReady_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
// 最可能的原因写进错误(profile 被占用)—— 而不是把超时白等完、只留一句「超时」。
func TestWaitDebugReady_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.waitDebugReady(context.Background(), deadPort(t), 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=", // 端口是**显式**的(具体号码每次不同,按前缀断言)
"chrome_output_bytes=",
} {
if !containsInLog(logs, want) {
t.Errorf("失败日志里缺少 %q:\n%s", want, logs)
}
}
}
// TestDial_SuccessDoesNotSpamChromeOutput 钉住反面:**成功时不刷屏**。正常启动时
// Chrome 也会往 stderr 写常规信息,那些不该进日志;只留一个字节数(Debug,供排查用)。
//
// 这里同时钉住新的就绪口径:假浏览器在**argv 给的那个显式端口**上监听并答 `/json/version`
// ⇒ Dial 成功,且把**这个端口**记进了 profile 目录的端口记录(下一次启动据此复用)。
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}, Serve: serveCDP, SleepMS: 5000,
}, 5*time.Second, log)
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("假浏览器在给定端口上服务了,Dial 应当成功: %v", err)
}
defer stop()
port, ok := readPortRecord(l.profileDir)
if !ok {
t.Fatalf("成功启动后必须记下端口(否则下一次启动无法复用):endpoint=%q", endpoint)
}
if endpoint != endpointURL(port) {
t.Errorf("endpoint = %q, want %q(必须是我们自己选并记下的那个端口)", endpoint, endpointURL(port))
}
if got, dok := DebugEndpoint(l.profileDir); !dok || got != endpoint {
t.Errorf("DebugEndpoint() = (%q,%v), want (%q,true)", got, dok, 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{"这一行不该出现:进程根本没被拉起"}, Serve: serveCDP, 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 := readPortRecord(l.profileDir); ok {
t.Error("预算耗尽时不该起浏览器(端口记录被写下了)")
}
if n := fakeLaunches(l.profileDir); n != 0 {
t.Errorf("预算耗尽时子进程不该存在(假浏览器被拉起 %d 次)", n)
}
if strings.Contains(buf.String(), "这一行不该出现") {
t.Errorf("预算耗尽时子进程不该存在:\n%s", buf.String())
}
}
// TestDial_RecordsExplicitPortAndReusesItOnSecondLaunch 是本片「端口记录 + 复用」的主判据:
// 第一次启动必须记下一个**显式端口**;同一个 profile 上的第二次启动必须**复用**它、**不再起
// 第二个进程**(假浏览器的启动计数是直接观察,不是猜)。
//
// 为什么这条是硬要求:改用显式端口后 Chromium 不再写 `DevToolsActivePort`(reference/
// chromium-cdp/03 §3.3),若我们自己不记端口,第二次启动就会去起第二个进程 —— 而 Chromium
// 会把命令委托给已在跑的那个后自己退出(真机实测),于是「复用」这条正确性路径彻底失灵。
func TestDial_RecordsExplicitPortAndReusesItOnSecondLaunch(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
first, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("第一次启动应当成功: %v", err)
}
defer stop()
if n := fakeLaunches(l.profileDir); n != 1 {
t.Fatalf("第一次启动应恰好起 1 个进程,实得 %d", n)
}
port, ok := readPortRecord(l.profileDir)
if !ok || port <= 0 {
t.Fatalf("必须记下显式端口,实得 port=%d ok=%v", port, ok)
}
if first != endpointURL(port) {
t.Fatalf("endpoint = %q, want %q", first, endpointURL(port))
}
second, stop2, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("第二次启动应当复用已存在的端点: %v", err)
}
defer stop2()
if second != first {
t.Errorf("第二次应复用它,实得 %q(第一次 %q)", second, first)
}
if n := fakeLaunches(l.profileDir); n != 1 {
t.Errorf("第二次不该再起进程(假浏览器被拉起 %d 次),日志:\n%s", n, buf.String())
}
if !strings.Contains(buf.String(), "browser debug endpoint reused") {
t.Errorf("复用必须留一条 INFO:\n%s", buf.String())
}
}
// TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken 钉住 TOCTOU 的处置:`listen(:0)` 取号
// 与 Chromium bind 之间,端口可能被别的进程抢走 —— 这时**换一个端口重试一次**(而不是把
// 「端口被占」报成「浏览器起不来」)。假浏览器第一次抢先占住端口答非 CDP、第二次才当 CDP,
// 因此这条用例同时证明「重试发生了」与「重试真的能成功」。
func TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveForeignThenCDP}, 3*time.Second, log)
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("换端口重试之后应当成功,实得: %v\n日志:\n%s", err, buf.String())
}
defer stop()
if n := fakeLaunches(l.profileDir); n != 2 {
t.Errorf("应当恰好重试一次(起 2 个进程),实得 %d:\n%s", n, buf.String())
}
if !strings.Contains(buf.String(), "retrying with another debug port") {
t.Errorf("换端口重试必须留一条 WARN(现场可查):\n%s", buf.String())
}
port, ok := readPortRecord(l.profileDir)
if !ok {
t.Fatal("成功之后必须记下最终用的端口")
}
if endpoint != endpointURL(port) {
t.Errorf("endpoint = %q, want %q(必须是重试后最终成功的那个端口)", endpoint, endpointURL(port))
}
}
// TestDial_ReportsPortTakenAfterRetryExhausted 是上一条的反面:端口**反复**被别人抢走时,
// 如实报「端口在听但不是可用的 CDP 端点」(不冒充「等端口超时」,也不冒充「进程退出」),
// 并且**不**留下端口记录(本次没有可用会话,留下记录会让下一次启动去探一个假端点)。
func TestDial_ReportsPortTakenAfterRetryExhausted(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveForeign}, 300*time.Millisecond, log)
_, stop, err := l.Dial(context.Background())
if stop != nil {
t.Error("失败路径不该交出停止函数")
}
if err == nil {
t.Fatal("端口一直被别人占着 ⇒ 必须如实报错")
}
t.Logf("实测 error=%v\n实测日志=%s", err, buf.String())
if !errors.Is(err, ErrLaunch) {
t.Fatalf("仍须包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerEndpointBroken) {
t.Errorf("应报「端口在听但不是可用的 CDP 端点」: %v", err)
}
if !strings.Contains(err.Error(), "处置") {
t.Errorf("应给可操作的处置建议: %v", err)
}
if n := fakeLaunches(l.profileDir); n != launchPortAttempts {
t.Errorf("应当试满 %d 次(每次换端口),实得 %d", launchPortAttempts, n)
}
if _, ok := readPortRecord(l.profileDir); ok {
t.Error("没有可用会话就不该留下端口记录(下一次启动会去探一个假端点)")
}
}
// TestDial_CallerDeadlineIsReportedAsBudget 与 TestDial_ThreeLaunchFailuresAreDistinguishable
// 这两条「失败分类」判据在 launchfailure_test.go(拆文件只为守住 AGENTS §10 的 500 行上限)。