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 整链路(未碰)。
This commit is contained in:
1 parent
74e171bc04
commit
6fe68ecfac
11 files changed
+1316
-320
No files matched your search
@@ -2,7 +2,9 @@ package browser
|
||||
|
||||
// 本文件是**假浏览器**:启动路径的测试必须真起一个子进程,才能证明
|
||||
// - 子进程的 stdout/stderr 被接住了(失败时上报、成功时不刷屏);
|
||||
// - 三种失败情形(进程立刻退出 / 端口没出来 / 预算没了)分辨得开。
|
||||
// - 三种失败情形(进程立刻退出 / 端点没就绪 / 预算没了)分辨得开;
|
||||
// - 「就绪」判的是**我们自己选的那个端口答不答 CDP**(显式端口下 Chromium 不再写
|
||||
// DevToolsActivePort,见 portrecord.go),因此假浏览器要真的在 argv 给的端口上监听。
|
||||
//
|
||||
// 手法是 Go 里既有的「重跑自己」范式(stdlib `os/exec` 的 testhelper 同款,本仓
|
||||
// `internal/capability/plugin/manager_test.go` 的 MCP stdio 假 server 也用它):
|
||||
@@ -18,6 +20,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -34,9 +38,29 @@ const (
|
||||
// fakeBrowserUserDataDirFlag 是脚本的定位方式:profile 目录就是 argv 里
|
||||
// `--user-data-dir=` 的值(真浏览器的同一个参数)。
|
||||
fakeBrowserUserDataDirFlag = "--user-data-dir="
|
||||
// fakeBrowserDebugPortFlag 是假浏览器的调试端口来源(与真浏览器的参数同名);
|
||||
// 显式端口是我们自己选的,假浏览器照它监听才算「就绪」。
|
||||
fakeBrowserDebugPortFlag = "--remote-debugging-port="
|
||||
// fakeBrowserLaunchLogFile 记录**每次**假浏览器被拉起(一行一次)。它是「有没有起
|
||||
// 第二个进程」这一判据的**直接观察**:复用与「预算耗尽不 spawn」两类用例都数它。
|
||||
fakeBrowserLaunchLogFile = "fake-browser-launches"
|
||||
// fakeBrowserNoScriptExit 是「argv 像浏览器、但没有脚本」时的退出码:宁可表现得像
|
||||
// 一个立刻退出的浏览器,也不递归跑测试套件(那是测试基建事故,不是用例失败)。
|
||||
// 一个立刻退出的浏览器,也不递归跑整个测试套件(那是测试基建事故,不是用例失败)。
|
||||
fakeBrowserNoScriptExit = 7
|
||||
// fakeBrowserServeIdle 是「要在端口上服务」时的默认存活时长:子进程必须活到用例
|
||||
// 判定完(真浏览器是被 Kill 才退的),但不能无限活着 —— 无限活着会漏成孤儿进程。
|
||||
fakeBrowserServeIdle = 30 * time.Second
|
||||
)
|
||||
|
||||
// 假浏览器在调试端口上的三种做派(见 fakeBrowserScript.Serve)。
|
||||
const (
|
||||
// serveCDP:监听并答合法的 `/json/version` —— 这就是「真浏览器已就绪」。
|
||||
serveCDP = "cdp"
|
||||
// serveForeign:监听但答一个**不是 CDP** 的响应 —— 模拟「我们选的端口被别人占了」
|
||||
// (TOCTOU 抢占),用于验证换端口重试。
|
||||
serveForeign = "foreign"
|
||||
// serveForeignThenCDP:第一次抢先占住端口、第二次才当 CDP —— 验证「重试之后真的成功」。
|
||||
serveForeignThenCDP = "foreign-then-cdp"
|
||||
)
|
||||
|
||||
// fakeBrowserScript 是一次「假启动」的行为脚本(字段零值 = 不做那件事)。
|
||||
@@ -46,17 +70,18 @@ type fakeBrowserScript struct {
|
||||
Stderr []string `json:"stderr"`
|
||||
// ExitCode 是退出码。
|
||||
ExitCode int `json:"exitCode"`
|
||||
// WritePort > 0 ⇒ 往 profile 目录写 DevToolsActivePort(模拟真浏览器就绪);
|
||||
// Dial 只读这个文件,不会去连端口,所以端口号不必真的有人在听。
|
||||
WritePort int `json:"writePort"`
|
||||
// Serve 决定它在 `--remote-debugging-port=` 那个端口上做什么(见上面三个常量);
|
||||
// 空串 = **不监听**(模拟「起了但从不就绪」)。
|
||||
Serve string `json:"serve"`
|
||||
// SleepMS > 0 ⇒ 之后继续活着(模拟浏览器常驻):由 Dial 的停止函数或超时路径 Kill。
|
||||
// Serve 非空时即使它为 0 也会活 fakeBrowserServeIdle(否则子进程可能在判定前就退了)。
|
||||
SleepMS int `json:"sleepMs"`
|
||||
}
|
||||
|
||||
// TestMain 让测试二进制可以**兼任**假浏览器。
|
||||
func TestMain(m *testing.M) {
|
||||
if dir, ok := fakeBrowserProfileDir(os.Args[1:]); ok {
|
||||
os.Exit(runFakeBrowser(dir))
|
||||
os.Exit(runFakeBrowser(dir, os.Args[1:]))
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -71,8 +96,24 @@ func fakeBrowserProfileDir(args []string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// fakeBrowserDebugPort 从 argv 里取出调试端口(找不到 / 不合法 ⇒ 0,表示「不监听」)。
|
||||
func fakeBrowserDebugPort(args []string) int {
|
||||
for _, a := range args {
|
||||
raw, ok := strings.CutPrefix(a, fakeBrowserDebugPortFlag)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
return 0
|
||||
}
|
||||
return port
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// runFakeBrowser 按脚本演一次假浏览器,返回退出码。
|
||||
func runFakeBrowser(profileDir string) int {
|
||||
func runFakeBrowser(profileDir string, args []string) int {
|
||||
raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserScriptFile))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
|
||||
@@ -83,25 +124,101 @@ func runFakeBrowser(profileDir string) int {
|
||||
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
|
||||
return fakeBrowserNoScriptExit
|
||||
}
|
||||
launchNo := recordFakeLaunch(profileDir)
|
||||
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 {
|
||||
if script.Serve != "" {
|
||||
if err := startFakeCDPServer(fakeBrowserDebugPort(args), serveKind(script.Serve, launchNo)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
|
||||
return fakeBrowserNoScriptExit
|
||||
}
|
||||
}
|
||||
if script.SleepMS > 0 {
|
||||
time.Sleep(fakeBrowserServeIdle)
|
||||
} else if script.SleepMS > 0 {
|
||||
time.Sleep(time.Duration(script.SleepMS) * time.Millisecond)
|
||||
}
|
||||
return script.ExitCode
|
||||
}
|
||||
|
||||
// serveKind 把脚本里的做派与**这是第几次被拉起**合起来算出本次的实际做派:
|
||||
// `foreign-then-cdp` 只在第一次抢端口,第二次起老老实实当 CDP —— 于是「换端口重试之后
|
||||
// 成功」这条路径可被断言,而不是只断言「重试过」。
|
||||
func serveKind(mode string, launchNo int) string {
|
||||
if mode == serveForeignThenCDP && launchNo > 1 {
|
||||
return serveCDP
|
||||
}
|
||||
if mode == serveForeignThenCDP {
|
||||
return serveForeign
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// recordFakeLaunch 追加一行「我起来了」,返回这是第几次(1 起)。计数落在 profile 目录里,
|
||||
// 因此每个用例各数各的。
|
||||
func recordFakeLaunch(profileDir string) int {
|
||||
path := filepath.Join(profileDir, fakeBrowserLaunchLogFile)
|
||||
raw, _ := os.ReadFile(path)
|
||||
n := fakeLaunchLines(string(raw))
|
||||
n++
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return n
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
_, _ = fmt.Fprintf(f, "launch %d\n", n)
|
||||
return n
|
||||
}
|
||||
|
||||
// fakeLaunchLines 数启动记录的行数(一行一次;空行不算)。
|
||||
func fakeLaunchLines(raw string) int {
|
||||
n := 0
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// startFakeCDPServer 在 port 上起一个假 CDP HTTP 端点(在后台协程里服务到进程被杀)。
|
||||
// port ≤ 0(argv 里没有可用端口)不服务:就绪判定因此不会成功,如实走失败路径。
|
||||
func startFakeCDPServer(port int, mode string) error {
|
||||
if port <= 0 {
|
||||
return nil
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
if mode == serveForeign {
|
||||
// 「端口在听但不是 CDP」:任何路径都答一段不是 CDP 的正文。
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<html>我不是 CDP,只是占着这个端口</html>"))
|
||||
})
|
||||
} else {
|
||||
mux.HandleFunc("/json/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0","webSocketDebuggerUrl":"ws://127.0.0.1:` +
|
||||
strconv.Itoa(port) + `/devtools/browser/fake"}`))
|
||||
})
|
||||
}
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("监听调试端口 %d: %w", port, err)
|
||||
}
|
||||
srv := &http.Server{Handler: mux}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeLaunches 数这个 profile 上假浏览器被拉起过几次(0 = 没起过任何进程)。
|
||||
func fakeLaunches(profileDir string) int {
|
||||
raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserLaunchLogFile))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return fakeLaunchLines(string(raw))
|
||||
}
|
||||
|
||||
// newFakeChrome 造一个「可执行文件 = 测试二进制自己」的 localChrome,并把脚本写进
|
||||
// profile 目录(`Dial` 会把它当 `--user-data-dir` 传下来)。
|
||||
func newFakeChrome(t *testing.T, script fakeBrowserScript, timeout time.Duration, log *slog.Logger) *localChrome {
|
||||
|
||||
Reference in new issue
Block a user