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
@@ -15,11 +15,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -124,11 +122,15 @@ func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并从 profile 读回它的调试端点。
|
||||
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并取回它的调试端点。
|
||||
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
|
||||
t.Fatalf("action=info(起真浏览器): %v", err)
|
||||
}
|
||||
endpoint := devToolsEndpoint(t, browsercfg.DefaultProfileDir(cfg.DataDir))
|
||||
endpoint, ok := browsercap.DebugEndpoint(browsercfg.DefaultProfileDir(cfg.DataDir))
|
||||
if !ok {
|
||||
t.Fatalf("会话已建立,但 profile %s 下没有端口记录(显式端口由我们自己记,见 portrecord.go)",
|
||||
browsercfg.DefaultProfileDir(cfg.DataDir))
|
||||
}
|
||||
if !endpointAlive(endpoint) {
|
||||
t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint)
|
||||
}
|
||||
@@ -151,22 +153,6 @@ func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
|
||||
endpoint, tailOfLog(out.String()))
|
||||
}
|
||||
|
||||
// devToolsEndpoint 从 profile 目录的 DevToolsActivePort 读出这次会话的调试端点
|
||||
// (格式与 launch.go 的 waitDevToolsPort 同源:首行是端口号)。
|
||||
func devToolsEndpoint(t *testing.T, profileDir string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(profileDir, "DevToolsActivePort"))
|
||||
if err != nil {
|
||||
t.Fatalf("读 DevToolsActivePort: %v(profile=%s)", err, profileDir)
|
||||
}
|
||||
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
|
||||
port, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil || port <= 0 {
|
||||
t.Fatalf("DevToolsActivePort 内容不合法: %q", raw)
|
||||
}
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
}
|
||||
|
||||
// endpointAlive 报告调试端点是否在应答(一次 /json/version)。
|
||||
func endpointAlive(endpoint string) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package browser
|
||||
|
||||
// 本文件是启动路径的**前置判定**:起进程之前先看「这个 profile 上是不是已经有一个浏览器」。
|
||||
// 现场(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 **exit 0**、不写新的
|
||||
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令
|
||||
// **委托给已在跑的那个实例**后自己退出。于是只按「等端口文件出现」实现的话,这一档永远等不到
|
||||
// 端口:上一次会话只要没退干净,整个浏览器档就不可用。
|
||||
// 现场(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 **exit 0**、不写新的端口
|
||||
// 记录,stderr 只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令**委托给已在跑的
|
||||
// 那个实例**后自己退出。于是只按「等新进程诞生」实现的话,这一档永远等不到可用端点:上一次
|
||||
// 会话只要没退干净,整个浏览器档就不可用。
|
||||
//
|
||||
// 三条判定与三条不同的出路(顺序即 Dial 里的调用顺序):
|
||||
//
|
||||
@@ -12,6 +12,10 @@ package browser
|
||||
// ② 端口记录在、但端口没人听 ⇒ 那是**陈记录**,清掉后照常启动;
|
||||
// ③ 端口在听却握不上手 ⇒ **如实报错**(带处置建议),不起进程 —— 绝不静默忽略。
|
||||
//
|
||||
// 判定用的端口来自 debugPortCandidates(portrecord.go):我们自己记的 `cyrene-debug-port`
|
||||
// 优先,再兼容一眼老版二进制留下的 `DevToolsActivePort`(换二进制时,上一版起的浏览器可能
|
||||
// 还在跑 —— 不认它就会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」)。
|
||||
//
|
||||
// 红线:这一路只做**出站**连接,不杀任何进程、不发任何信号去抢 profile、不开任何监听端口
|
||||
// (AGENTS §9);主人的浏览器可能正开着,我们只能连它或绕开它。
|
||||
|
||||
@@ -20,59 +24,90 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// probeExistingEndpoint 探「profile 目录里记的调试端点还能不能用」——启动路径的第一步。
|
||||
// probeExistingEndpoint 探「这个 profile 上记的调试端点还能不能用」——启动路径的第一步。
|
||||
// 三条结论各自对应一条**不同于启动**的出路:
|
||||
//
|
||||
// ("", nil) 没有可复用的端点:端口记录缺席,或记的端口**已经没人听**(陈文件)
|
||||
// ⇒ 调用方清掉陈记录后正常启动(见 clearStalePortFile);
|
||||
// ("", nil) 没有可复用的端点:一个记录都没有,或记录的端口**已经没人听**(陈记录)
|
||||
// ⇒ 调用方清掉陈记录后正常启动(见 clearStalePortRecords);
|
||||
// (url, nil) 端口在听、且 `/json/version` 有**合法响应** ⇒ 复用它,不再起第二个进程;
|
||||
// ("", err) 端口在听、但握手失败 ⇒ **如实报错**(launchMarkerEndpointBroken),不起进程。
|
||||
//
|
||||
// 为什么先独立做一次 TCP 探活(而不是把 HTTP 探活的失败一分为二):两条出路必须分开 ——
|
||||
// 「没人听」是**陈文件**(可以安全清掉后照常启动),「在听却不答 CDP」是**占用/异常**(只能
|
||||
// 「没人听」是**陈记录**(可以安全清掉后照常启动),「在听却不答 CDP」是**占用/异常**(只能
|
||||
// 如实报)。混成一句「探测失败 ⇒ 直接启动」,就会在真被占用时把一个必然「委托出去就退出」的
|
||||
// 空壳进程当成本次启动,把原因退化成一句查不明白的「启动失败」。
|
||||
//
|
||||
// 探测超时给得很短(adoptProbeTimeout):这是「要不要复用」的判断,不该拖住启动路径。
|
||||
// 探测超时给得很短(endpointProbeTimeout):这是「要不要复用」的判断,不该拖住启动路径。
|
||||
func (l *localChrome) probeExistingEndpoint(ctx context.Context) (string, error) {
|
||||
port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile))
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
if !portListening(ctx, port) {
|
||||
return "", nil
|
||||
}
|
||||
endpoint := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
probeCtx, cancel := context.WithTimeout(ctx, adoptProbeTimeout)
|
||||
defer cancel()
|
||||
var version struct {
|
||||
Browser string `json:"Browser"`
|
||||
}
|
||||
handshakeErr := httpGetJSON(probeCtx, endpoint+"/json/version", &version)
|
||||
if handshakeErr == nil && version.Browser != "" {
|
||||
return endpoint, nil
|
||||
}
|
||||
if handshakeErr == nil {
|
||||
handshakeErr = errors.New("响应是合法 JSON,但没有 Browser 字段(不像 CDP 的 /json/version)")
|
||||
var brokenPort int
|
||||
var brokenCause error
|
||||
for _, port := range debugPortCandidates(l.profileDir) {
|
||||
state, cause := probeDebugEndpoint(ctx, port)
|
||||
switch state {
|
||||
case endpointReady:
|
||||
return endpointURL(port), nil
|
||||
case endpointForeign:
|
||||
if brokenCause == nil {
|
||||
brokenPort, brokenCause = port, cause
|
||||
}
|
||||
case endpointAbsent:
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
// 探测期间调用方的预算先没了:这是「没时间了」,**不**说端点坏了。
|
||||
return "", budgetLaunchErr(ctx)
|
||||
}
|
||||
return "", fmt.Errorf("%w: %s:%s 在听,但 /json/version 握手失败:%v。%s",
|
||||
ErrLaunch, launchMarkerEndpointBroken, endpoint, handshakeErr, endpointBrokenHint(l.profileDir))
|
||||
if brokenCause != nil {
|
||||
return "", fmt.Errorf("%w: %s:%s 在听,但 /json/version 握手失败:%v。%s",
|
||||
ErrLaunch, launchMarkerEndpointBroken, endpointURL(brokenPort), brokenCause, endpointBrokenHint(l.profileDir))
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// portListening 报告 127.0.0.1:port 上有没有人在听(这一次探测的连接立刻关掉)。它只做
|
||||
// debugEndpointState 是一次探活的三态结论。
|
||||
type debugEndpointState int
|
||||
|
||||
const (
|
||||
// endpointAbsent:这个端口上没人听(连接被拒)—— 记它下来的那条记录是**陈记录**。
|
||||
endpointAbsent debugEndpointState = iota
|
||||
// endpointReady:在听,且 `/json/version` 答得上一个带 Browser 字段的合法响应 ⇒ 就是 CDP。
|
||||
endpointReady
|
||||
// endpointForeign:在听,但答的不是可用的 CDP(拒答 / 非 JSON / 缺 Browser 字段)——
|
||||
// 只能**如实报**,不能当成本次会话的端点。
|
||||
endpointForeign
|
||||
)
|
||||
|
||||
// probeDebugEndpoint 探一个端口的三态,并带回「不合法」那一态的原因(用于如实上报)。
|
||||
// 它只做出站连接(AGENTS §9),不启动进程、不发信号。
|
||||
//
|
||||
// 三个判定为什么不能压成一个 bool:「没人听」与「在听但不是 CDP」的处置**完全不同**(前者
|
||||
// 清记录后照常启动,后者只能如实报),压扁就必然丢掉其中一条。
|
||||
func probeDebugEndpoint(ctx context.Context, port int) (debugEndpointState, error) {
|
||||
if !portListening(ctx, port) {
|
||||
return endpointAbsent, nil
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, endpointProbeTimeout)
|
||||
defer cancel()
|
||||
var version struct {
|
||||
Browser string `json:"Browser"`
|
||||
}
|
||||
err := httpGetJSON(probeCtx, endpointURL(port)+"/json/version", &version)
|
||||
if err == nil && version.Browser != "" {
|
||||
return endpointReady, nil
|
||||
}
|
||||
if err == nil {
|
||||
err = errors.New("响应是合法 JSON,但没有 Browser 字段(不像 CDP 的 /json/version)")
|
||||
}
|
||||
return endpointForeign, err
|
||||
}
|
||||
|
||||
// portListening 报告回环上有没有人在听这个端口(这一次探测的连接立刻关掉)。它只做
|
||||
// **出站**连接,不开任何监听端口(AGENTS §9)。
|
||||
func portListening(ctx context.Context, port int) bool {
|
||||
d := net.Dialer{Timeout: adoptProbeTimeout}
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
d := net.Dialer{Timeout: endpointProbeTimeout}
|
||||
conn, err := d.DialContext(ctx, "tcp", loopbackAddr(port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -80,26 +115,6 @@ func portListening(ctx context.Context, port int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// clearStalePortFile 清掉**上一轮**留下的端口记录 —— 只有确认「端口已经没人听」才会走到这里。
|
||||
//
|
||||
// 为什么必须清:`waitDevToolsPort` 只认这一个文件,上一轮留下的端口号会被当成**本次**的就绪
|
||||
// 结果 —— 于是「这一次没启动成功」会伪装成「端点已就绪」,调用方拿到一个死端点。
|
||||
//
|
||||
// 为什么可以清:这个文件是浏览器启动时写下的**一条记录**,不是锁。真正在跑、真正拥有这个
|
||||
// profile 的实例一定有一个**活着的**端口,那种情况上一步已经把它复用走了。因此删掉一条指向死
|
||||
// 端口的记录,不可能从任何活着的实例手里拿走什么 —— 它影响的只有我们自己的就绪判定。
|
||||
//
|
||||
// 文件本来就不在(ErrNotExist)不算失败;别的错误(被占用 / 权限)**如实报**并且**不继续
|
||||
// 启动** —— 留着一个陈记录继续启动,只会把陈端口读成本次结果,那正是本方法要防的事。
|
||||
func (l *localChrome) clearStalePortFile() error {
|
||||
path := filepath.Join(l.profileDir, devToolsPortFile)
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("%w: 清掉上一轮的端口记录 %s 失败: %v(留着它会把陈端口当成本次就绪)",
|
||||
ErrLaunch, path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withChromeOutput 把浏览器自己说的话附进**返回给调用方**的错误里。日志里本来就有它,但用户
|
||||
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
|
||||
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
|
||||
@@ -116,7 +131,7 @@ func withChromeOutput(err error, preview string) error {
|
||||
func endpointBrokenHint(profileDir string) string {
|
||||
return fmt.Sprintf("处置:先确认是谁占着这个端口(本代理不会、也不该杀任何进程);"+
|
||||
"确认它不是本 profile 的浏览器调试端点后,删掉 %s 再重试,或把 profileDir 指到另一个目录。",
|
||||
filepath.Join(profileDir, devToolsPortFile))
|
||||
portRecordPath(profileDir))
|
||||
}
|
||||
|
||||
// profileBusyHint 是「新进程立刻退出」的处置建议:Chromium 把命令委托给了同一个 profile 上已在
|
||||
|
||||
@@ -7,19 +7,20 @@ package browser
|
||||
// ③ 端口在听却握不上手 ⇒ 如实报错(带处置建议),不起进程;
|
||||
// ④ 被占用且拿不到端点 ⇒ 情况②之外的「真被占用」:如实报 + 带上 Chrome 自己的原话。
|
||||
//
|
||||
// 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**诱饵** ——
|
||||
// 这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器
|
||||
// (fakebrowser_test.go 的重跑自己范式)。真走了启动路径,喊声会进日志、Dial 也会失败;
|
||||
// 用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒ 判据①立刻变红。)
|
||||
// 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**两重观察** ——
|
||||
// ① **诱饵**:这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器
|
||||
// (fakebrowser_test.go 的重跑自己范式),真走了启动路径,喊声会进日志、Dial 也会失败;
|
||||
// ② **启动计数**:假浏览器每次被拉起都会往 profile 目录追一行(`fakeLaunches`),可以直接
|
||||
// 数出「起了几次」。用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒
|
||||
// 判据①立刻变红。)
|
||||
//
|
||||
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的
|
||||
// DevToolsActivePort,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把
|
||||
// 命令委托给了已在跑的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
|
||||
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的端口
|
||||
// 记录,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令委托给了已在跑
|
||||
// 的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -62,19 +63,29 @@ func portOfEndpoint(t *testing.T, endpoint string) int {
|
||||
return port
|
||||
}
|
||||
|
||||
// writeDevToolsPortFile 写一条端口记录(真浏览器的格式:首行端口、次行 browser_guid)。
|
||||
func writeDevToolsPortFile(t *testing.T, dir string, port int) {
|
||||
// portFileExists 报告**我们自己的**端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。
|
||||
func portFileExists(dir string) bool {
|
||||
_, err := os.Stat(portRecordPath(dir))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// writePortRecordFile 写一条我们自己的端口记录(实现见 portrecord.go;格式:一行端口号)。
|
||||
func writePortRecordFile(t *testing.T, dir string, port int) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, devToolsPortFile)
|
||||
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\nGUID\n"), 0o600); err != nil {
|
||||
if err := writePortRecord(dir, port); err != nil {
|
||||
t.Fatalf("写端口记录: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// portFileExists 报告端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。
|
||||
func portFileExists(dir string) bool {
|
||||
_, err := os.Stat(filepath.Join(dir, devToolsPortFile))
|
||||
return err == nil
|
||||
// writeLegacyDevToolsPortFile 写一条**老格式**的端口记录(Chromium 在 `port==0` 时写的
|
||||
// `DevToolsActivePort`:首行端口、次行 browser_guid)—— 用它钉住「换二进制后上一版起的浏览器
|
||||
// 仍能被复用」这条兼容路径。
|
||||
func writeLegacyDevToolsPortFile(t *testing.T, dir string, port int) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, legacyDevToolsPortFile)
|
||||
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\nGUID\n"), 0o600); err != nil {
|
||||
t.Fatalf("写老格式端口记录: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 是判据①:profile 里记的调试端点
|
||||
@@ -87,7 +98,7 @@ func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) {
|
||||
cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil })
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log)
|
||||
writeDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
|
||||
writePortRecordFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
|
||||
|
||||
endpoint, stop, err := l.Dial(context.Background())
|
||||
if err != nil {
|
||||
@@ -108,31 +119,90 @@ func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) {
|
||||
if !strings.Contains(logs, "source="+logSource) {
|
||||
t.Errorf("这条 INFO 必须带来源(AGENTS §10 禁无来源裸日志):\n%s", logs)
|
||||
}
|
||||
if n := fakeLaunches(l.profileDir); n != 0 {
|
||||
t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次)", n)
|
||||
}
|
||||
stop() // 复用来的实例不归我们杀:停止函数必须是空操作(能调用即通过)
|
||||
}
|
||||
|
||||
// TestDial_ReusesLegacyDevToolsActivePort 钉住一条**兼容**路径:老版二进制(那时我们传的是
|
||||
// `--remote-debugging-port=0`)在 profile 里留下的 `DevToolsActivePort` 若还活着,也必须被认领。
|
||||
//
|
||||
// 为什么值得单独钉:换二进制时上一版起的浏览器可能还在跑(本项目的后端常驻、浏览器按需回收)。
|
||||
// 若不认那个文件,新二进制会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」,
|
||||
// 于是功能一直不可用,直到那个浏览器自己空闲回收。
|
||||
func TestDial_ReusesLegacyDevToolsActivePort(t *testing.T) {
|
||||
t.Parallel()
|
||||
cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil })
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log)
|
||||
writeLegacyDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
|
||||
|
||||
endpoint, stop, err := l.Dial(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("老格式记录指向的活端点也该被复用,实得: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
if endpoint != cdp.base {
|
||||
t.Errorf("endpoint = %q, want %q", endpoint, cdp.base)
|
||||
}
|
||||
if n := fakeLaunches(l.profileDir); n != 0 {
|
||||
t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次):\n%s", n, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDial_ClearsStaleLegacyRecord 补上「陈记录清理」的另一半:**老格式**记录指向的端口已经
|
||||
// 没人听时,它也必须被清掉(否则下一次启动还会去探一个死端点),且不挡住正常启动。
|
||||
func TestDial_ClearsStaleLegacyRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, _ := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
|
||||
writeLegacyDevToolsPortFile(t, l.profileDir, deadPort(t))
|
||||
|
||||
endpoint, stop, err := l.Dial(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("陈的老格式记录不该挡住正常启动: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
port, ok := readPortRecord(l.profileDir)
|
||||
if !ok || endpoint != endpointURL(port) {
|
||||
t.Fatalf("成功之后应当记下本次用的端口:endpoint=%q port=%d ok=%v", endpoint, port, ok)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(l.profileDir, legacyDevToolsPortFile)); err == nil {
|
||||
t.Error("陈的老格式记录应当已被清掉(留着它下次还会去探那个死端口)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDial_ClearsStalePortFileThenLaunches 是判据②:端口记录在、但端口已经没人听 ⇒ 那是
|
||||
// **陈记录**,清掉后照常启动。两个子用例各钉一半:
|
||||
//
|
||||
// (1) 清掉之后,本次就绪判定认**新进程写的**那个端口;
|
||||
// (2) 清掉是**真清**:新进程不写端口时,Dial 必须报「等端口超时」—— 而不是把陈端口当成
|
||||
// (1) 清掉之后,本次就绪判定认**新进程实际服务的**那个端口;
|
||||
// (2) 清掉是**真清**:新进程从不就绪时,Dial 必须报「等端口就绪超时」—— 而不是把陈端口当成
|
||||
// 本次结果稀里糊涂地成功(那正是「不清陈记录」会造成的伪装)。
|
||||
func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("以新进程写的端口为准", func(t *testing.T) {
|
||||
t.Run("以新进程实际服务的端口为准", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, _ := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{WritePort: 55543, SleepMS: 3000}, 5*time.Second, log)
|
||||
writeDevToolsPortFile(t, l.profileDir, deadPort(t))
|
||||
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
|
||||
stale := deadPort(t)
|
||||
writePortRecordFile(t, l.profileDir, stale)
|
||||
|
||||
endpoint, stop, err := l.Dial(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("陈记录不该挡住正常启动: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
if want := "http://127.0.0.1:55543"; endpoint != want {
|
||||
t.Errorf("endpoint = %q, want %q(陈端口被当成本次结果了?)", endpoint, want)
|
||||
port, ok := readPortRecord(l.profileDir)
|
||||
if !ok {
|
||||
t.Fatal("成功之后必须记下本次用的端口")
|
||||
}
|
||||
if endpoint != endpointURL(port) {
|
||||
t.Errorf("endpoint = %q, want %q", endpoint, endpointURL(port))
|
||||
}
|
||||
if port == stale {
|
||||
t.Errorf("陈端口(%d)被当成本次结果了", stale)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -140,23 +210,23 @@ func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{
|
||||
Stderr: []string{"起来了,但从不写端口记录"}, SleepMS: 3000,
|
||||
Stderr: []string{"起来了,但从不答 CDP"}, SleepMS: 3000,
|
||||
}, 300*time.Millisecond, log)
|
||||
stale := deadPort(t)
|
||||
writeDevToolsPortFile(t, l.profileDir, stale)
|
||||
writePortRecordFile(t, l.profileDir, stale)
|
||||
|
||||
endpoint, _, err := l.Dial(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("陈记录被清掉、新进程又不写端口 ⇒ 必须报错;实得 endpoint=%q", endpoint)
|
||||
t.Fatalf("陈记录被清掉、新进程又从不就绪 ⇒ 必须报错;实得 endpoint=%q", endpoint)
|
||||
}
|
||||
t.Logf("实测 error=%v\n实测日志=%s", err, buf.String())
|
||||
if !errors.Is(err, ErrLaunch) {
|
||||
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), launchMarkerPortTimeout) {
|
||||
t.Errorf("应当读成「等 DevToolsActivePort 超时」: %v", err)
|
||||
t.Errorf("应当读成「等调试端口就绪超时」: %v", err)
|
||||
}
|
||||
if endpoint == fmt.Sprintf("http://127.0.0.1:%d", stale) {
|
||||
if endpoint == endpointURL(stale) {
|
||||
t.Errorf("把陈端口当成本次结果了(endpoint=%q)", endpoint)
|
||||
}
|
||||
if portFileExists(l.profileDir) {
|
||||
@@ -191,7 +261,7 @@ func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) {
|
||||
defer srv.Close()
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 3*time.Second, log)
|
||||
writeDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, srv.URL))
|
||||
writePortRecordFile(t, l.profileDir, portOfEndpoint(t, srv.URL))
|
||||
|
||||
_, stop, err := l.Dial(context.Background())
|
||||
if stop != nil {
|
||||
@@ -207,7 +277,7 @@ func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), launchMarkerEndpointBroken) {
|
||||
t.Errorf("错误里应点明这一类(端口在听但不是 CDP 端点): %v", err)
|
||||
}
|
||||
for _, want := range []string{"处置", devToolsPortFile, l.profileDir} {
|
||||
for _, want := range []string{"处置", portRecordFile, l.profileDir} {
|
||||
if !containsInLog(err.Error(), want) {
|
||||
t.Errorf("可操作的处置建议里缺少 %q: %v", want, err)
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func runCompareOnce(t *testing.T, rawURL string, g compareGroup, repeat int, run
|
||||
|
||||
rec := compareRecord{
|
||||
TS: time.Now().Format(time.RFC3339), Group: g.Name, URL: rawURL,
|
||||
Mode: modeName(g.Headless), LaunchFlags: drv.launchArgs(),
|
||||
Mode: modeName(g.Headless), LaunchFlags: drv.launchArgs(portOfEndpoint(t, s.endpoint)),
|
||||
CDPRuntimeEnable: false, // 本驱动从源头**不发** Runtime.enable(reference/06 的 P0)
|
||||
Driver: "cyrene-go-cdp", ChromeVersion: chromeVersionOf(t, s.endpoint),
|
||||
UserDataDir: profile, Headless: g.Headless,
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
//
|
||||
// 红线(design/28 §3/§4、AGENTS §9/§10):
|
||||
//
|
||||
// - **不新开对外监听端口**:调试端口由 Chromium 自己绑在 `127.0.0.1` 的**随机**
|
||||
// 端口(`--remote-debugging-port=0`,端口从 profile 目录的 `DevToolsActivePort`
|
||||
// 读回),我们是**出站连接方**;容器路径同理(我们连它)。
|
||||
// - **不新开对外监听端口**:调试端口是**我们选号、Chromium 自己绑在 `127.0.0.1`** 的
|
||||
// 显式端口(`--remote-debugging-port=<port>`,端口号由 `listen(:0)` 借内核挑一个空闲号
|
||||
// 后立刻放掉,并记在 `<profile>/cyrene-debug-port` 里供下次复用,见 portrecord.go)——
|
||||
// 本包**不持有任何监听端口**,始终是**出站连接方**;容器路径同理(我们连它)。
|
||||
// 为什么不传 `--remote-debugging-port=0`:Chromium 会把「端口=0」当作 ChromeDriver 式
|
||||
// 自动化启动、主动点亮 `navigator.webdriver`(reference/chromium-cdp/00 §2)。
|
||||
// - **profile 含凭据**(cookie / 登录态):目录 0700、不得位于 git 工作树内
|
||||
// (`browsercfg.EnsureProfileDir`),内容绝不进上下文与长期记忆。
|
||||
// - **截图可能含凭据/二维码**:文件 0600、默认不进长期记忆(design/28 §4)。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,12 +15,7 @@ import (
|
||||
|
||||
// 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。
|
||||
const (
|
||||
// devToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写回端口号的
|
||||
// 文件名(profile 目录下):首行端口、次行 browser_guid
|
||||
// (reference/browser-stealth/10 §2.4,依据 content/browser/devtools/devtools_http_handler.cc)。
|
||||
devToolsPortFile = "DevToolsActivePort"
|
||||
|
||||
// portPollInterval 是「等调试端口就绪」的轮询间隔:冷启动通常 0.3~1.5s,
|
||||
// portPollInterval 是「等调试端点就绪」的轮询间隔:冷启动通常 0.3~1.5s,
|
||||
// 100ms 的粒度既不迟钝也不白烧 CPU。
|
||||
portPollInterval = 100 * time.Millisecond
|
||||
|
||||
@@ -32,30 +25,32 @@ const (
|
||||
defaultLang = "zh-CN"
|
||||
)
|
||||
|
||||
// adoptProbeTimeout 是「要不要复用已在跑的实例」这一次探测的超时(也是远端端点探活
|
||||
// 用的那一档):它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得短 ——
|
||||
// 这是启动前的判定,不该拖住启动路径。
|
||||
const adoptProbeTimeout = 2 * time.Second
|
||||
// endpointProbeTimeout 是一次端点探活的超时:「要不要复用已在跑的实例」与「本次启动就绪了
|
||||
// 没有」两处共用同一档。它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得
|
||||
// 短 —— 前者是启动前的判定,后者是就绪轮询里的一次尝试,都不该拖住启动路径。
|
||||
const endpointProbeTimeout = 2 * time.Second
|
||||
|
||||
// 启动失败的**分类标记**:几种情形必须在错误消息里一眼可分(design/28 §7 判据 4
|
||||
// 「如实标注」/ §6 坑 5「失败要如实报」)——
|
||||
//
|
||||
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
|
||||
// ② 我们自己的等待窗口用完(DevToolsActivePort 没出现);
|
||||
// ② 我们自己的等待窗口用完(**我们选的那个**调试端口一直没答上 CDP);
|
||||
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了;
|
||||
// ④ 上一轮记的调试端口**在听、但不是可用的 CDP 端点**(握手失败)—— 它既不是「进程退出」
|
||||
// 也不是「端口没出现」,必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。
|
||||
// ④ 端口在听、但不是可用的 CDP 端点(握手失败)—— 它既不是「进程退出」也不是「端口没出现」,
|
||||
// 必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。这一条有三个入口:
|
||||
// 启动**前**探到旧记录指向坏端点、启动**中**发现选的端口被别人占了、以及会话就绪后的
|
||||
// 复用判定(都在本包内共用同一个标记)。
|
||||
//
|
||||
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
|
||||
const (
|
||||
// launchMarkerProcessExited 标记 ①。
|
||||
launchMarkerProcessExited = "浏览器进程立刻退出"
|
||||
// launchMarkerPortTimeout 标记 ②。
|
||||
launchMarkerPortTimeout = "等 DevToolsActivePort 超时"
|
||||
launchMarkerPortTimeout = "等调试端口就绪超时"
|
||||
// launchMarkerBudget 标记 ③。
|
||||
launchMarkerBudget = "调用方预算耗尽"
|
||||
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈文件」(端口没人听)
|
||||
// 是**两回事**:陈文件可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
|
||||
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈记录」(端口没人听)
|
||||
// 是**两回事**:陈记录可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
|
||||
// 进程,换来的只是「委托给已跑实例后立刻退出」,把原因糊掉。
|
||||
launchMarkerEndpointBroken = "调试端口在听但不是可用的 CDP 端点"
|
||||
)
|
||||
@@ -84,11 +79,14 @@ type dialer interface {
|
||||
|
||||
// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。
|
||||
//
|
||||
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地:
|
||||
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地,并经
|
||||
// `DevDocs/reference/chromium-cdp/` 的官方源码 + 本机实测修订了两处(见 portrecord.go 的文件头):
|
||||
// - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、
|
||||
// `--no-default-browser-check`、`--disable-blink-features=AutomationControlled`、
|
||||
// `--remote-debugging-port=0`;
|
||||
// - **不传**:`--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
|
||||
// `--no-default-browser-check`、`--remote-debugging-port=<我们选的显式端口>`;
|
||||
// - **不传**:`--disable-blink-features=AutomationControlled`(显式端口下它对
|
||||
// `navigator.webdriver` 已是空操作,却仍是整条命令行里唯一命中官方 `kBadFlags` 的参数 ⇒
|
||||
// 每次有头启动弹一条「不受支持的命令行标记」信息栏)、
|
||||
// `--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
|
||||
// `--disable-extensions` / `--disable-default-apps` /
|
||||
// `--disable-component-extensions-with-background-pages` / `--disable-component-update` /
|
||||
// `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、
|
||||
@@ -96,9 +94,10 @@ type dialer interface {
|
||||
//
|
||||
// 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍:
|
||||
// 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows
|
||||
// 上不支持**。因此 Windows 主路径用次选 `--remote-debugging-port=0`(内核分配随机
|
||||
// 回环端口,从 profile 目录的 DevToolsActivePort 读回):**仍是我们出站连它**,
|
||||
// 不开任何对外监听端口(AGENTS §9)。
|
||||
// 上不支持**;何况官方源码里 pipe 与 `--headless` 一样会打开 `AutomationControlled`
|
||||
// (reference/chromium-cdp/02 §4.1),换它并不能解决问题。因此 Windows 主路径用
|
||||
// `--remote-debugging-port=<显式端口>`:**仍是我们出站连它**,不开任何对外监听端口
|
||||
// (AGENTS §9;端口由内核分配号、Chromium 自己 bind 在 127.0.0.1,见 portrecord.go)。
|
||||
type localChrome struct {
|
||||
exe string
|
||||
profileDir string
|
||||
@@ -121,7 +120,7 @@ func (l *localChrome) Describe() string {
|
||||
// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。
|
||||
func (l *localChrome) Local() bool { return true }
|
||||
|
||||
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
|
||||
// Dial 拉起浏览器并等调试端点就绪。任何一步失败都返回带分类原因的 ErrLaunch。
|
||||
//
|
||||
// 四条**先于启动**的判定(前三条都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
|
||||
//
|
||||
@@ -129,22 +128,24 @@ func (l *localChrome) Local() bool { return true }
|
||||
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
|
||||
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
|
||||
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
|
||||
// 2. **能复用就复用**(`probeExistingEndpoint`):profile 目录的 DevToolsActivePort 还在、
|
||||
// 端口**确实在听**、`/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
|
||||
// 2. **能复用就复用**(`probeExistingEndpoint`):这个 profile 上记的调试端口还在
|
||||
// (`cyrene-debug-port`,或老二进制留下的 `DevToolsActivePort`)、端口**确实在听**、
|
||||
// `/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
|
||||
// 这不是优化,是**正确性**:同一个 user-data-dir 上起第二个实例时 Chromium 会把手上的
|
||||
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的
|
||||
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远
|
||||
// 不写端口 ⇒ 不复用的话,这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合
|
||||
// design/28 §2「profile 长期保留、任务间复用」。
|
||||
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的端口记录,
|
||||
// stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远不就绪 ⇒ 不复用的话,
|
||||
// 这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合 design/28 §2「profile
|
||||
// 长期保留、任务间复用」。
|
||||
// 3. **端口在听、却握不上手 ⇒ 如实报错**(`launchMarkerEndpointBroken`):绝不「忽略掉它、
|
||||
// 自己起一个」—— 占着这个端口的多半就是同一个 profile 的浏览器,起进程只会让新进程委托
|
||||
// 出去后立刻退出,把一句本就查不明白的失败再糊一层。
|
||||
// 4. **陈的端口记录先清掉**(`clearStalePortFile`):只在真的要走启动路径时才清;不清的话,
|
||||
// 上一轮的端口会被当成本次结果(理由见那个方法的注释)。
|
||||
// 4. **陈的记录先清掉**(`clearStalePortRecords`):只在真的要走启动路径时才清;不清的话,
|
||||
// 上一轮的端口会被当成本次可复用的端点(理由见那个方法的注释)。
|
||||
//
|
||||
// 子进程的 stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次
|
||||
// argv 一起报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的
|
||||
// 工具结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
|
||||
// 起进程与「等它就绪」由 launchWithRetry(含一次换端口重试)与 launchOnce 承担;子进程的
|
||||
// stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次 argv 一起
|
||||
// 报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的工具
|
||||
// 结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
|
||||
//
|
||||
// 停止函数只对**本次启动**的进程有效(复用来的实例不归我们杀):进程退出后 Chrome 的
|
||||
// 渲染进程随之退出,因此进程级 Kill 足够。
|
||||
@@ -172,11 +173,57 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
||||
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
|
||||
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
|
||||
}
|
||||
if err := l.clearStalePortFile(); err != nil {
|
||||
if err := l.clearStalePortRecords(); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return l.launchWithRetry(ctx)
|
||||
}
|
||||
|
||||
args := l.launchArgs()
|
||||
// launchWithRetry 起浏览器,并把**本次实际用的那个端口**记进 profile 目录的端口记录
|
||||
// (`cyrene-debug-port`,见 portrecord.go:显式端口下 Chromium 不写 DevToolsActivePort,
|
||||
// 端口只能我们自己记 —— 记不下就等于下一次启动无法复用,所以写失败按硬失败报,并把刚起的
|
||||
// 进程收掉,不留孤儿)。
|
||||
//
|
||||
// 只在一种情形下**换端口**重试:证据表明我们选的端口被别人占了(`portTakenError`,即就绪
|
||||
// 等待期间那个端口在听、却答不上 CDP)。那是「listen(:0) 取号」与「Chromium bind」之间的
|
||||
// TOCTOU,换一个号就好,且**没有**碰任何不属于我们的进程。其余失败一律如实上报 —— 无差别
|
||||
// 重试只会把原因糊掉,还把预算烧在重复等待上。
|
||||
func (l *localChrome) launchWithRetry(ctx context.Context) (string, func(), error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < launchPortAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
// 重试之前预算先没了:这一轮的事实是「没时间了」,如实报(不是端口的问题)。
|
||||
return "", nil, budgetLaunchErr(ctx)
|
||||
}
|
||||
port, err := pickDebugPort(ctx)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w: %v", ErrLaunch, err)
|
||||
}
|
||||
endpoint, stop, err := l.launchOnce(ctx, port)
|
||||
if err == nil {
|
||||
if werr := writePortRecord(l.profileDir, port); werr != nil {
|
||||
stop()
|
||||
return "", nil, fmt.Errorf("%w: %v", ErrLaunch, werr)
|
||||
}
|
||||
return endpoint, stop, nil
|
||||
}
|
||||
lastErr = err
|
||||
var taken *portTakenError
|
||||
if !errors.As(err, &taken) {
|
||||
return "", nil, err
|
||||
}
|
||||
l.log.Warn("browser launch retrying with another debug port",
|
||||
"port", port, "profile", l.profileDir, "error", err)
|
||||
}
|
||||
// 换端口也还是被别人抢:**如实报同一类**(端口在听但不是 CDP),不冒充别的失败。
|
||||
// 这时 lastErr 里已带标记与处置建议,直接交回它,不另编一句更好听的。
|
||||
return "", nil, lastErr
|
||||
}
|
||||
|
||||
// launchOnce 起一次浏览器(用给定的显式端口)并等它就绪。失败时**自己收掉刚起的进程**,
|
||||
// 并把浏览器原话与本次 argv 报进日志、把原话附进返回的错误。
|
||||
func (l *localChrome) launchOnce(ctx context.Context, port int) (string, func(), error) {
|
||||
args := l.launchArgs(port)
|
||||
cmd := exec.Command(l.exe, args...)
|
||||
out := &chromeOutput{}
|
||||
cmd.Stdout = out
|
||||
@@ -191,7 +238,7 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
||||
// 于是 exited 关闭时缓冲里就是子进程说过的**全部**话(下面要在失败时上报它)。
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
port, err := l.waitDevToolsPort(ctx, exited)
|
||||
endpoint, err := l.waitDebugReady(ctx, port, exited)
|
||||
if err != nil {
|
||||
stopProcess(cmd)
|
||||
l.waitExited(exited)
|
||||
@@ -205,7 +252,7 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
||||
}
|
||||
l.log.Debug("browser launched",
|
||||
"exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes())
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", port), func() { stopProcess(cmd) }, nil
|
||||
return endpoint, func() { stopProcess(cmd) }, nil
|
||||
}
|
||||
|
||||
// waitExited 等子进程真正收尾(管道里的在途字节落进缓冲)再上报:Kill 之后 Wait 还要
|
||||
@@ -231,15 +278,22 @@ func budgetLaunchErr(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// 启动前的三条判定与它们的处置文案在 adopt.go:复用活端点(probeExistingEndpoint)、
|
||||
// 清掉陈的端口记录(clearStalePortFile)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
|
||||
// 清掉陈的端口记录(clearStalePortRecords)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
|
||||
|
||||
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
|
||||
func (l *localChrome) launchArgs() []string {
|
||||
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。port 是我们
|
||||
// **已经选好**的显式调试端口(取号与记录见 portrecord.go)。
|
||||
func (l *localChrome) launchArgs(port int) []string {
|
||||
args := []string{
|
||||
// profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。
|
||||
"--user-data-dir=" + l.profileDir,
|
||||
// 内核分配随机回环端口;端口从 DevToolsActivePort 读回(见本类型文档)。
|
||||
"--remote-debugging-port=0",
|
||||
// **显式**端口(不是 `=0`):Chromium 把 `port==0` 当作 ChromeDriver 式自动化启动,
|
||||
// 会主动打开 Blink 的 AutomationControlled ⇒ `navigator.webdriver === true`(官方
|
||||
// runtime_features.cc:389-404;实测 reference/chromium-cdp/07 的 g1 vs g7);显式端口
|
||||
// 不被这样处理,因此这一条同时替掉了原来的
|
||||
// `--disable-blink-features=AutomationControlled`(那个 flag 在显式端口下是空操作,
|
||||
// 却会命中官方 kBadFlags、每次有头启动弹一条「不受支持的命令行标记」信息栏)。
|
||||
// 代价:Chromium 只在 port==0 时才写 <profile>/DevToolsActivePort,端口改由我们自己记。
|
||||
fmt.Sprintf("--remote-debugging-port=%d", port),
|
||||
// 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝;
|
||||
// reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」,
|
||||
// 不开任何对外服务面。
|
||||
@@ -248,8 +302,6 @@ func (l *localChrome) launchArgs() []string {
|
||||
"--no-default-browser-check",
|
||||
fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH),
|
||||
"--lang=" + defaultLang,
|
||||
// 兜底防线:真正的关键是**不传** `--enable-automation`(见类型文档)。
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"about:blank",
|
||||
}
|
||||
if l.headless {
|
||||
@@ -259,14 +311,18 @@ func (l *localChrome) launchArgs() []string {
|
||||
return args
|
||||
}
|
||||
|
||||
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**,
|
||||
// 而且三者互不冒充(design/28 §7 判据 4「如实标注」):
|
||||
// waitDebugReady 等**我们自己选的那个端口**真的成为可用的 CDP 端点(就绪判据从「端口文件
|
||||
// 出现」换成了「端点答得上 `/json/version`」—— 显式端口下 Chromium 不再写端口文件,而且
|
||||
// 这样判别的是**事实**而不是一条可能陈旧的记录)。三条出口都要**说清原因**,而且三者互不
|
||||
// 冒充(design/28 §7 判据 4「如实标注」):
|
||||
//
|
||||
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
|
||||
// launchMarkerProcessExited **与可操作的处置建议**(profileBusyHint);子进程说的原话
|
||||
// 由 Dial 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
|
||||
// 由 launchOnce 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
|
||||
// 「正在现有的浏览器会话中打开。」;
|
||||
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;
|
||||
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;若这期间那个端口**在听却答不上
|
||||
// CDP**,说明它被别的进程抢走了(listen(:0) 取号与 Chromium bind 之间的 TOCTOU),
|
||||
// 改报 launchMarkerEndpointBroken 并交给 launchWithRetry 换端口重试(portTakenError);
|
||||
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
|
||||
//
|
||||
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
|
||||
@@ -275,54 +331,67 @@ func (l *localChrome) launchArgs() []string {
|
||||
//
|
||||
// 还有一条**不走这里**的出口:启动前探到「端口在听却握不上手」时,Dial 会在起进程之前就
|
||||
// 报 launchMarkerEndpointBroken(见 adopt.go)—— 那种情形根本不该起这个进程。
|
||||
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
|
||||
path := filepath.Join(l.profileDir, devToolsPortFile)
|
||||
func (l *localChrome) waitDebugReady(ctx context.Context, port int, exited <-chan struct{}) (string, error) {
|
||||
launchDeadline := time.Now().Add(l.timeout)
|
||||
deadline := time.NewTimer(l.timeout)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(portPollInterval)
|
||||
defer ticker.Stop()
|
||||
foreign := false
|
||||
for {
|
||||
if port, ok := readDevToolsPort(path); ok {
|
||||
return port, nil
|
||||
switch state, _ := probeDebugEndpoint(ctx, port); state {
|
||||
case endpointReady:
|
||||
return endpointURL(port), nil
|
||||
case endpointForeign:
|
||||
foreign = true
|
||||
case endpointAbsent:
|
||||
}
|
||||
select {
|
||||
case <-exited:
|
||||
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
|
||||
// 浏览器进程自己退了,端点不会再就绪 —— **立刻**报,不把超时白等完。
|
||||
// 最常见的原因是同一 profile 已被另一个实例占用:Chromium 把命令**委托给已在跑的
|
||||
// 那个**之后自己退出(真机实测 stderr 原话「正在现有的浏览器会话中打开。」)、新
|
||||
// 进程不写 DevToolsActivePort。这里给可操作建议,但不碰别人的进程。
|
||||
return 0, fmt.Errorf("%w: %s。%s", ErrLaunch, launchMarkerProcessExited, profileBusyHint(l.profileDir))
|
||||
// 进程不写端口记录。这里给可操作建议,但不碰别人的进程。
|
||||
return "", fmt.Errorf("%w: %s。%s", ErrLaunch, launchMarkerProcessExited, profileBusyHint(l.profileDir))
|
||||
case <-ctx.Done():
|
||||
if dl, ok := ctx.Deadline(); !ok || dl.Add(launchDeadlineSlack).Before(launchDeadline) {
|
||||
return 0, budgetLaunchErr(ctx)
|
||||
return "", budgetLaunchErr(ctx)
|
||||
}
|
||||
return 0, l.portTimeoutErr()
|
||||
return "", l.portTimeoutErr(port, foreign)
|
||||
case <-deadline.C:
|
||||
return 0, l.portTimeoutErr()
|
||||
return "", l.portTimeoutErr(port, foreign)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// portTimeoutErr 是 ②「我们自己的等待窗口用完」的唯一文案(两条出口共用,口径只有一处)。
|
||||
func (l *localChrome) portTimeoutErr() error {
|
||||
return fmt.Errorf("%w: %s(%s)", ErrLaunch, launchMarkerPortTimeout, l.timeout)
|
||||
// foreign=true 时改报「端口在听但不是可用的 CDP 端点」并带上换端口重试的标记:那是被别的
|
||||
// 进程抢占的样子,与「浏览器起不来」是两回事。
|
||||
func (l *localChrome) portTimeoutErr(port int, foreign bool) error {
|
||||
if foreign {
|
||||
return &portTakenError{err: fmt.Errorf(
|
||||
"%w: %s(%s):%s 在听,但一直没有答出可用的 CDP 响应(可能被别的进程抢走了端口)。%s",
|
||||
ErrLaunch, launchMarkerEndpointBroken, l.timeout, endpointURL(port), portTakenHint())}
|
||||
}
|
||||
return fmt.Errorf("%w: %s(%s):%s 一直没有应答", ErrLaunch, launchMarkerPortTimeout, l.timeout, endpointURL(port))
|
||||
}
|
||||
|
||||
// readDevToolsPort 读端口文件的首行;文件还没出现 / 还没写完 / 内容不合法都返回 false
|
||||
// (交给调用方继续轮询,而不是把「正在写」误判成「配错」)。
|
||||
func readDevToolsPort(path string) (int, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
|
||||
port, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
return 0, false
|
||||
}
|
||||
return port, true
|
||||
// portTakenError 标记「这一次失败是因为我们选的端口被别人占了」:它只在**进程内**用来决定
|
||||
// 「换一个端口再试一次」(错误文案仍是 launchMarkerEndpointBroken —— 与启动前探到坏端点
|
||||
// 共用同一类,因为对用户来说都是「端口在听但不是 CDP」)。
|
||||
type portTakenError struct{ err error }
|
||||
|
||||
// Error 实现 error(文案由 portTimeoutErr 一次写清,这里不另编一句更好听的)。
|
||||
func (e *portTakenError) Error() string { return e.err.Error() }
|
||||
|
||||
// Unwrap 让 errors.Is / errors.As 仍能穿过这一层(ErrLaunch 口径不变)。
|
||||
func (e *portTakenError) Unwrap() error { return e.err }
|
||||
|
||||
// portTakenHint 是「我们选的端口被抢」的处置建议:重试即可(会自动换端口),但绝不代杀别人的进程。
|
||||
func portTakenHint() string {
|
||||
return "处置:会自动换一个端口重试一次;若反复出现,先查是谁占着这个端口 —— " +
|
||||
"本代理不杀任何不属于自己的进程。"
|
||||
}
|
||||
|
||||
// remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium,
|
||||
|
||||
@@ -16,16 +16,22 @@ import (
|
||||
// TestLaunchArgs_HeadfulCarriesNoAutomationTraces 是本片**最重要的一条测试**:它把
|
||||
// `DevDocs/reference/browser-stealth/10 §2.1` 的黑名单钉成可执行判据 ——
|
||||
// 有头路径**不许**出现任何 `--headless*`,也不许出现「自动化味道」的参数
|
||||
// (`--enable-automation` 以及 Playwright/Puppeteer 默认传的那一串)。
|
||||
// (`--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()
|
||||
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",
|
||||
@@ -41,20 +47,23 @@ func TestLaunchArgs_HeadfulCarriesNoAutomationTraces(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchArgs_RequiredFlagsPresent 钉住「必须传」的那几条:profile、随机回环调试
|
||||
// 端口、窗口尺寸、语言、免首次运行、以及 AutomationControlled 兜底防线。
|
||||
// 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()
|
||||
args := l.launchArgs(port)
|
||||
for _, want := range []string{
|
||||
"--user-data-dir=/tmp/prof",
|
||||
"--remote-debugging-port=0",
|
||||
"--remote-debugging-port=9411",
|
||||
"--window-size=1024,768",
|
||||
"--lang=zh-CN",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--remote-allow-origins=*",
|
||||
"about:blank",
|
||||
} {
|
||||
@@ -62,47 +71,64 @@ func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) {
|
||||
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()
|
||||
args := l.launchArgs(9412)
|
||||
if len(args) == 0 || args[0] != "--headless=new" {
|
||||
t.Errorf("无头档的第一个参数应为 --headless=new,实得:%v", args)
|
||||
}
|
||||
if !containsArg(args, "--disable-blink-features=AutomationControlled") {
|
||||
t.Error("无头档也必须保留 AutomationControlled 防线")
|
||||
if !containsArg(args, "--remote-debugging-port=9412") {
|
||||
t.Errorf("无头档也必须用显式调试端口:%v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadDevToolsPort 钉住端口文件解析:正常两行、只有一行、缺文件、垃圾内容。
|
||||
func TestReadDevToolsPort(t *testing.T) {
|
||||
// TestReadPortNumberFile 钉住「首行是端口号」的解析:我们自己的记录(一行端口)与
|
||||
// Chromium 的 `DevToolsActivePort`(首行端口、次行 browser_guid)共用同一个解析。
|
||||
func TestReadPortNumberFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, devToolsPortFile)
|
||||
path := filepath.Join(dir, portRecordFile)
|
||||
|
||||
if _, ok := readDevToolsPort(path); ok {
|
||||
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 := readDevToolsPort(path); !ok || port != 54321 {
|
||||
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 := readDevToolsPort(path); ok {
|
||||
if _, ok := readPortNumberFile(path); ok {
|
||||
t.Error("垃圾内容不该报 ok")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := readDevToolsPort(path); ok {
|
||||
if _, ok := readPortNumberFile(path); ok {
|
||||
t.Error("端口 0 不该报 ok")
|
||||
}
|
||||
}
|
||||
@@ -120,10 +146,10 @@ func TestLocalChromeDescribe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalChromeAdoptsLiveEndpoint 钉住「认领已在跑的实例」:profile 目录里的
|
||||
// DevToolsActivePort 还活着 ⇒ Dial 直接交出那个端点、**不启动任何进程**。
|
||||
// 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) {
|
||||
@@ -137,9 +163,7 @@ func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
@@ -151,35 +175,33 @@ func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
|
||||
stop() // 认领来的实例不归我们杀:停止函数必须是空操作(调用不 panic 即通过)
|
||||
}
|
||||
|
||||
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口文件在、但没人听 ⇒ **不认领**
|
||||
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口记录在、但没人听 ⇒ **不认领**
|
||||
// (否则会连到一个死端口,比启动失败更难排查)。它同时钉住「没人听」的**分类**:
|
||||
// 探测要给出 ("", nil) 而不是错误 —— 这是「陈文件,可以清掉后照常启动」的那一条出路。
|
||||
// 探测要给出 ("", 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)
|
||||
}
|
||||
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)
|
||||
t.Fatalf("死端口是「陈记录」而不是「坏端点」,探测不该报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitDevToolsPort_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
|
||||
// TestWaitDebugReady_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
|
||||
// 最可能的原因写进错误(profile 被占用)—— 而不是把超时白等完、只留一句「超时」。
|
||||
func TestWaitDevToolsPort_ReportsEarlyExit(t *testing.T) {
|
||||
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.waitDevToolsPort(context.Background(), exited)
|
||||
_, err := l.waitDebugReady(context.Background(), deadPort(t), exited)
|
||||
if err == nil {
|
||||
t.Fatal("进程退出后应当报错")
|
||||
}
|
||||
@@ -233,7 +255,7 @@ func TestDial_ReportsChromeOutputAndArgvOnLaunchFailure(t *testing.T) {
|
||||
complaint, // 浏览器自己说的话
|
||||
l.exe, // 是哪个可执行文件
|
||||
"--user-data-dir=" + l.profileDir, // 本次 argv(profile 路径不含凭据)
|
||||
"--remote-debugging-port=0",
|
||||
"--remote-debugging-port=", // 端口是**显式**的(具体号码每次不同,按前缀断言)
|
||||
"chrome_output_bytes=",
|
||||
} {
|
||||
if !containsInLog(logs, want) {
|
||||
@@ -244,21 +266,31 @@ func TestDial_ReportsChromeOutputAndArgvOnLaunchFailure(t *testing.T) {
|
||||
|
||||
// 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}, WritePort: 55543, SleepMS: 5000,
|
||||
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)
|
||||
t.Fatalf("假浏览器在给定端口上服务了,Dial 应当成功: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
if endpoint != "http://127.0.0.1:55543" {
|
||||
t.Errorf("endpoint = %q", endpoint)
|
||||
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) {
|
||||
@@ -276,7 +308,7 @@ func TestDial_ExhaustedBudgetDoesNotSpawnBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{
|
||||
Stderr: []string{"这一行不该出现:进程根本没被拉起"}, WritePort: 55543, SleepMS: 5000,
|
||||
Stderr: []string{"这一行不该出现:进程根本没被拉起"}, Serve: serveCDP, SleepMS: 5000,
|
||||
}, 5*time.Second, log)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -295,104 +327,122 @@ func TestDial_ExhaustedBudgetDoesNotSpawnBrowser(t *testing.T) {
|
||||
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
|
||||
t.Errorf("预算耗尽不许被说成「端口等超时」(那是浏览器侧的问题): %v", err)
|
||||
}
|
||||
if _, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile)); ok {
|
||||
t.Error("预算耗尽时不该起浏览器(脚本被执行了)")
|
||||
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_CallerDeadlineIsReportedAsBudget 钉住**真机那条路**:等待期间调用方的截止
|
||||
// 时间到了(这一轮的工具预算烧完),错误必须读成「预算耗尽、浏览器没坏」,而不是
|
||||
// 「等待调试端口时被取消」这种既像浏览器故障、又说不清谁取消的文案。
|
||||
func TestDial_CallerDeadlineIsReportedAsBudget(t *testing.T) {
|
||||
// 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{Stderr: []string{"浏览器在等,但没人给它时间"}, SleepMS: 5000}, 10*time.Second, log)
|
||||
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
_, _, err := l.Dial(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("调用方截止时间到之后 Dial 必须报错")
|
||||
first, stop, err := l.Dial(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("第一次启动应当成功: %v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrLaunch) {
|
||||
t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err)
|
||||
defer stop()
|
||||
if n := fakeLaunches(l.profileDir); n != 1 {
|
||||
t.Fatalf("第一次启动应恰好起 1 个进程,实得 %d", n)
|
||||
}
|
||||
if !strings.Contains(err.Error(), launchMarkerBudget) {
|
||||
t.Errorf("错误必须读成「预算耗尽」,实得: %v", err)
|
||||
port, ok := readPortRecord(l.profileDir)
|
||||
if !ok || port <= 0 {
|
||||
t.Fatalf("必须记下显式端口,实得 port=%d ok=%v", port, ok)
|
||||
}
|
||||
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
|
||||
t.Errorf("调用方预算先到时不许报成我们自己的端口等待超时: %v", err)
|
||||
if first != endpointURL(port) {
|
||||
t.Fatalf("endpoint = %q, want %q", first, endpointURL(port))
|
||||
}
|
||||
if !containsInLog(buf.String(), l.profileDir) {
|
||||
t.Errorf("这条失败也要带 argv(否则现场没法复现):\n%s", buf.String())
|
||||
|
||||
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_ThreeLaunchFailuresAreDistinguishable 钉住「一眼分辨」:三条出口 ——
|
||||
// ① 进程立刻退出 / ② 我们自己的端口等待超时 / ③ 调用方预算耗尽 —— 各自的错误文案里
|
||||
// 必须带**互不相同**的标记,谁也别冒充谁。
|
||||
func TestDial_ThreeLaunchFailuresAreDistinguishable(t *testing.T) {
|
||||
// TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken 钉住 TOCTOU 的处置:`listen(:0)` 取号
|
||||
// 与 Chromium bind 之间,端口可能被别的进程抢走 —— 这时**换一个端口重试一次**(而不是把
|
||||
// 「端口被占」报成「浏览器起不来」)。假浏览器第一次抢先占住端口答非 CDP、第二次才当 CDP,
|
||||
// 因此这条用例同时证明「重试发生了」与「重试真的能成功」。
|
||||
func TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken(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,
|
||||
},
|
||||
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())
|
||||
}
|
||||
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
|
||||
})
|
||||
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 行上限)。
|
||||
@@ -0,0 +1,109 @@
|
||||
package browser
|
||||
|
||||
// 本文件只放「失败怎么分类/怎么报」这一类判据(实现见 launch.go 的 budgetLaunchErr /
|
||||
// portTimeoutErr 与 lift 里的三条出口)。它们与 launch_test.go 的其余 Dial 用例分开,
|
||||
// 一是为了每个文件都不超 AGENTS §10 的 500 行上限,二是让「三类失败必须一眼可分」这一组
|
||||
// 判据集中在一处可读。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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{"起来了但从不答 CDP"}, 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package browser
|
||||
|
||||
// 本文件是「调试端口的来源与落点」这一件事的**唯一出处**:取一个空闲端口、记在 profile
|
||||
// 目录里、需要时读回来、陈记录清掉。
|
||||
//
|
||||
// 为什么改成自己记端口(DevDocs/reference/chromium-cdp/00 的「方案 A」,主人 2026-10-07 拍板):
|
||||
//
|
||||
// - Chromium 把 `--remote-debugging-port=0` 当作 **ChromeDriver 式自动化启动**,会主动打开
|
||||
// Blink 的 `AutomationControlled` ⇒ `navigator.webdriver === true`(官方
|
||||
// content/child/runtime_features.cc:389-404;本机实测见 07 的 g1 vs g7);**显式端口**
|
||||
// 不会被这样处理(官方注释原文:调用方给具体端口时「更可能是挂调试器」,故不动该特性);
|
||||
// - 我们因此**不再传** `--disable-blink-features=AutomationControlled`:它在显式端口下已是
|
||||
// 空操作,却仍是整条命令行里唯一命中官方 `kBadFlags` 的参数 ⇒ 每次有头启动都弹一条
|
||||
// 「不受支持的命令行标记」信息栏(01 的源码 + 07 的 g2/g7 实测)。去掉它,webdriver
|
||||
// **本来就是 false**,横幅也随之消失;
|
||||
// - 代价:Chromium **只在 `port==0` 时**才写 `<profile>/DevToolsActivePort`
|
||||
// (官方 chrome/browser/devtools/remote_debugging_server.cc:363-395;实测 03 §3.3)——
|
||||
// 改显式端口后那个文件不再出现,端口只能由**我们自己**记。
|
||||
//
|
||||
// 落点选 **profile 目录**里的 `cyrene-debug-port`(我们自有的记录:Chrome 既不读也不写它),
|
||||
// 而不是 `$DATA_DIR/browser/` 下另开一份状态文件:
|
||||
//
|
||||
// - 它天然按 profile 隔离 —— 认领判定(adopt.go 的 probeExistingEndpoint)只需看一个目录,
|
||||
// 不必再维护「profile → 记录」的映射与它们的失效时机;
|
||||
// - 与 Chrome 自己的 `DevToolsActivePort` 同目录,两者可以并列读(debugPortCandidates);
|
||||
// - profile 被删 / 被换 / 被搬走时记录随之消失,不会留下一条指向别人的陈端口。
|
||||
//
|
||||
// 记录内容只有一行端口号,**不含任何凭据**(profile 里的 cookie / 登录态在别的文件里)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// portRecordFile 是我们自己的调试端口记录(首行端口号)。
|
||||
portRecordFile = "cyrene-debug-port"
|
||||
|
||||
// legacyDevToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写的那个文件
|
||||
// (reference/chromium-cdp/03 §3.2:首行端口、次行 browser_guid)。改用显式端口后我们
|
||||
// **不再依赖它**,但仍然读一眼 —— 换二进制时上一版(port=0)起的浏览器可能还在跑:
|
||||
// 不认它就会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」。
|
||||
legacyDevToolsPortFile = "DevToolsActivePort"
|
||||
|
||||
// debugPortPickAttempts 是「取一个空闲端口」的尝试次数。做法:`listen(:0)` 让内核分配一个
|
||||
// 空闲回环端口、随即关闭,把号交给 Chromium 去 bind —— 这中间存在极小的 TOCTOU 抢占窗口
|
||||
// (AGENTS §9:我们自己**不开**监听端口,这里只是「借内核挑个号」再立刻放掉),抢不到就再取。
|
||||
debugPortPickAttempts = 5
|
||||
|
||||
// launchPortAttempts 是本机启动的尝试次数:1 次正常 + 1 次**换端口**重试。重试只发生在
|
||||
// 有证据表明「我们选的端口被别人占了」时(见 portTakenError);其余失败一律如实上报 ——
|
||||
// 无差别重试只会把失败原因糊掉,还把预算烧在重复等待上。
|
||||
launchPortAttempts = 2
|
||||
)
|
||||
|
||||
// endpointURL 把端口号变成 CDP HTTP 端点的地址(127.0.0.1:调试端点在回环上,我们出站连它)。
|
||||
func endpointURL(port int) string {
|
||||
return "http://" + loopbackAddr(port)
|
||||
}
|
||||
|
||||
// loopbackAddr 拼一个回环地址(探查、记录、启停都用同一处拼法,避免两处写法漂移)。
|
||||
func loopbackAddr(port int) string {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// pickDebugPort 取一个**当前空闲**的回环端口号交给 Chromium:listen(:0) 由内核分配、立刻
|
||||
// 关闭(本进程不再持有它,AGENTS §9 的「不常驻监听端口」因此成立)。关掉之后再到手之间
|
||||
// 有极小的被抢窗口,故取到一个「此刻确实没人听」的号才返回,最多试 debugPortPickAttempts 次。
|
||||
func pickDebugPort(ctx context.Context) (int, error) {
|
||||
for attempt := 0; attempt < debugPortPickAttempts; attempt++ {
|
||||
ln, err := net.Listen("tcp", loopbackAddr(0))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("listen(:0) 取空闲端口: %w", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
if err := ln.Close(); err != nil {
|
||||
continue
|
||||
}
|
||||
if !portListening(ctx, port) {
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("连续 %d 次取到的空闲端口都已被占用", debugPortPickAttempts)
|
||||
}
|
||||
|
||||
// portRecordPath 返回这个 profile 的端口记录文件的路径。
|
||||
func portRecordPath(profileDir string) string {
|
||||
return filepath.Join(profileDir, portRecordFile)
|
||||
}
|
||||
|
||||
// writePortRecord 记下**本次实际使用的**调试端口。写失败不算成功的一半:记不下端口就等于
|
||||
// 下一次启动无法复用(会去起第二个进程、然后被 Chromium 委托出去),因此调用方必须把它当
|
||||
// 硬失败(见 launchWithRetry)。
|
||||
func writePortRecord(profileDir string, port int) error {
|
||||
path := portRecordPath(profileDir)
|
||||
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("写调试端口记录 %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPortRecord 读回我们自己记的端口;文件不在 / 内容不合法都返回 false(交给调用方当
|
||||
// 「没有可复用的记录」处理,而不是把「正在写」误判成「配错」)。
|
||||
func readPortRecord(profileDir string) (int, bool) {
|
||||
return readPortNumberFile(portRecordPath(profileDir))
|
||||
}
|
||||
|
||||
// DebugEndpoint 报告这个 profile 上**已知**的调试端点(我们自己记的端口记录,见本文件头)。
|
||||
// 它只读文件、不探活、不启动任何进程,供排障与端到端用例取「这一次会话用的是哪个端点」。
|
||||
// 返回 ("", false) 表示没有记录或记录不合法 —— **不猜**端口。
|
||||
func DebugEndpoint(profileDir string) (string, bool) {
|
||||
port, ok := readPortRecord(profileDir)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return endpointURL(port), true
|
||||
}
|
||||
|
||||
// debugPortCandidates 返回「这个 profile 上可能有 CDP 的端口」候选,按可信度排序:先我们
|
||||
// 自己的记录,再那一眼兼容的老文件(legacyDevToolsPortFile),去重。
|
||||
func debugPortCandidates(profileDir string) []int {
|
||||
var out []int
|
||||
if port, ok := readPortRecord(profileDir); ok {
|
||||
out = append(out, port)
|
||||
}
|
||||
legacy := filepath.Join(profileDir, legacyDevToolsPortFile)
|
||||
if port, ok := readPortNumberFile(legacy); ok && (len(out) == 0 || out[0] != port) {
|
||||
out = append(out, port)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readPortNumberFile 读一个「首行是端口号」的文件;文件不在 / 还没写完 / 内容不合法都返回
|
||||
// false。两种文件都套这个格式:我们自己的 portRecordFile(一行端口)与 Chrome 的
|
||||
// legacyDevToolsPortFile(首行端口、次行 browser_guid)。
|
||||
func readPortNumberFile(path string) (int, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
|
||||
port, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
return 0, false
|
||||
}
|
||||
return port, true
|
||||
}
|
||||
|
||||
// clearStalePortRecords 清掉**上一轮**留下的端口记录(两种都清)—— 只有确认「候选端口都已经
|
||||
// 没人听」才会走到这里(见 Dial 的调用顺序)。
|
||||
//
|
||||
// 为什么必须清:这两条记录是「这个 profile 上有一个 CDP 端点」的**唯一线索**,上一轮的端口
|
||||
// 留着只会让下一次启动的认领判定去探一个死端口。
|
||||
//
|
||||
// 为什么可以清:它们只是浏览器启动时写下的**一条记录**,不是锁。真正在跑、真正拥有这个
|
||||
// profile 的实例一定有一个**活着的**端口,那种情况上一步(probeExistingEndpoint)已经把它
|
||||
// 复用走了。因此删掉指向死端口的记录,不可能从任何活着的实例手里拿走什么 —— 它影响的只有
|
||||
// 我们自己的认领判定。
|
||||
//
|
||||
// 文件本来就不在(ErrNotExist)不算失败;别的错误(被占用 / 权限)**如实报**并且**不继续
|
||||
// 启动** —— 留着一条陈记录继续启动,只会把陈端口读成本次可复用的端点。
|
||||
func (l *localChrome) clearStalePortRecords() error {
|
||||
for _, path := range []string{portRecordPath(l.profileDir), filepath.Join(l.profileDir, legacyDevToolsPortFile)} {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("%w: 清掉上一轮的端口记录 %s 失败: %v(留着它会把陈端口当成本次可复用的端点)",
|
||||
ErrLaunch, path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package browser
|
||||
|
||||
// 本文件是**真机判据**(真 Chrome + 真显式端口 + 临时 profile):方案 A
|
||||
// (reference/chromium-cdp/00)落地后的端到端自证,默认 Skip,只在显式开闸时跑:
|
||||
//
|
||||
// CYRENE_BROWSER_CDP_E2E=1 go test ./internal/capability/browser/ -run TestRealChromeCDP -v -timeout 5m
|
||||
//
|
||||
// 它证明五件事,每件都取自**真进程 / 真页面**而不是我们的以为:
|
||||
//
|
||||
// ① `navigator.webdriver === false`(改前用 `--remote-debugging-port=0` 实测是 true,见
|
||||
// reference/chromium-cdp/07 的 g1/g7);
|
||||
// ② 真 Chrome 的**命令行原话**里是显式端口,且没有 `--disable-blink-features` /
|
||||
// `--enable-automation` / `--headless`(从 Win32_Process 读回);
|
||||
// ③ 其余判据与 07 §2.2 的矩阵一致(UA 不含 HeadlessChrome、plugins.length=5、
|
||||
// languages=zh-CN、window.chrome 在)—— 即这次改动**只动了端口写法**;
|
||||
// ④ 同一个 profile 上的**第二次** Dial 复用同一个端点、**不起第二个浏览器进程**;
|
||||
// ⑤ 窗口内**没有** bad-flags 横幅(UI Automation 读控件树原文;有横幅时长这样:
|
||||
// 「您使用的是不受支持的命令行标记:--disable-blink-features=…」,见 07 §三)。
|
||||
//
|
||||
// 安全边界(硬要求):profile 一律落在 `t.TempDir()`(%TEMP% 下),**绝不触碰**
|
||||
// `$DATA_DIR/browser/profile`;收尾只按**这个唯一临时 profile 路径**筛 PID,不按进程名杀。
|
||||
// assertProfileIsTemporary 是防误杀主人浏览器的闸。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
)
|
||||
|
||||
// realPluginsLength 是 reference/chromium-cdp/07 §2.2 实测的 `navigator.plugins.length`
|
||||
// (九组一致)。它只用来钉「这次改动没动其余判据」,不是新的期望值来源。
|
||||
const realPluginsLength = 5
|
||||
|
||||
// realUIAminNodes 是 UI Automation 控件树的最少命名节点数:低于它说明**没读到窗口**,
|
||||
// 那种情况下的「没有横幅」是「读不到」而不是「没有」,必须判红(positive control)。
|
||||
const realUIAminNodes = 5
|
||||
|
||||
// cdpE2EEnabled 报告这次运行是否开了真机闸(与 wire_browser_lifecycle_test.go 的
|
||||
// CYRENE_BROWSER_E2E 同一套路:真机用例不混进普通 go test)。
|
||||
func cdpE2EEnabled() bool { return os.Getenv("CYRENE_BROWSER_CDP_E2E") == "1" }
|
||||
|
||||
// TestRealChromeCDP_WebdriverFalseAndPortReuse 是真机主判据(默认 Skip,用法见文件头)。
|
||||
func TestRealChromeCDP_WebdriverFalseAndPortReuse(t *testing.T) {
|
||||
if !cdpE2EEnabled() {
|
||||
t.Skip("未设 CYRENE_BROWSER_CDP_E2E=1:真机用例默认不跑(用法见本文件顶部)")
|
||||
}
|
||||
if os.PathSeparator != '\\' {
|
||||
t.Skip("本用例依赖 Windows 的 Win32_Process / UI Automation")
|
||||
}
|
||||
exe, ok := browsercfg.NewProber().FindLocalBrowser()
|
||||
if !ok {
|
||||
t.Skip("本机没有 Chrome/Chromium,真机用例无法进行")
|
||||
}
|
||||
profile := filepath.Join(t.TempDir(), "profile-cdp-e2e")
|
||||
assertProfileIsTemporary(t, profile)
|
||||
t.Logf("真机前置:chrome=%s\n临时 profile=%s", exe, profile)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
log, logs := newCapturedLog(slog.LevelDebug)
|
||||
|
||||
l := &localChrome{
|
||||
exe: exe, profileDir: profile, headless: false,
|
||||
viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight,
|
||||
timeout: 90 * time.Second, log: log,
|
||||
}
|
||||
cfg := configView{
|
||||
profileDir: profile, shotsDir: t.TempDir(), headless: false,
|
||||
viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight,
|
||||
timeout: 90 * time.Second, idle: time.Minute, shotMaxBytes: browsercfg.DefaultScreenshotMaxBytes,
|
||||
}
|
||||
defer killChromeForProfile(t, profile)
|
||||
|
||||
s, err := openSession(ctx, l, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("起真 Chrome 会话: %v\n日志:\n%s", err, logs.String())
|
||||
}
|
||||
endpoint := s.endpoint
|
||||
port, ok := readPortRecord(profile)
|
||||
if !ok {
|
||||
t.Fatalf("成功启动后必须记下端口(%s)", portRecordFile)
|
||||
}
|
||||
if endpoint != endpointURL(port) {
|
||||
t.Fatalf("endpoint = %q, want %q", endpoint, endpointURL(port))
|
||||
}
|
||||
t.Logf("① 启动成功:endpoint=%s(我们自己记下的端口 = %d)", endpoint, port)
|
||||
|
||||
// ── 判据 ②:真进程的命令行(Win32_Process 读回的原话) ─────────────────────────
|
||||
procs := chromeProcsForProfile(t, profile, false)
|
||||
if len(procs) != 1 {
|
||||
t.Fatalf("按临时 profile 路径筛出的浏览器主进程应恰好 1 个,实得 %d:%+v", len(procs), procs)
|
||||
}
|
||||
browserPID := procs[0].PID
|
||||
t.Logf("② 真 Chrome 命令行(PID %d,原话):\n%s", browserPID, procs[0].CommandLine)
|
||||
cmdline := procs[0].CommandLine
|
||||
for _, want := range []string{
|
||||
"--remote-debugging-port=" + strconv.Itoa(port),
|
||||
"--user-data-dir=" + profile,
|
||||
"--remote-allow-origins=*",
|
||||
} {
|
||||
if !strings.Contains(cmdline, want) {
|
||||
t.Errorf("真 Chrome 命令行里应有 %q", want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"--disable-blink-features", "--enable-blink-features", "--enable-automation", "--headless",
|
||||
} {
|
||||
if strings.Contains(cmdline, bad) {
|
||||
t.Errorf("真 Chrome 命令行里不该有 %q(reference/chromium-cdp/01/02)", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 判据 ①/③:页面自报的判据 ──────────────────────────────────────────────────
|
||||
fp, err := readFingerprint(ctx, s)
|
||||
if err != nil {
|
||||
t.Fatalf("读真机指纹: %v", err)
|
||||
}
|
||||
extra := readRealPageSignals(t, ctx, s)
|
||||
t.Logf("① 页面自报 navigator.webdriver=%v\n③ %s", fp.Webdriver, extra.summary())
|
||||
if fp.Webdriver {
|
||||
t.Errorf("navigator.webdriver 必须为 false(显式端口不该点亮 AutomationControlled)")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(fp.UserAgent), "headless") {
|
||||
t.Errorf("UA 里不该出现 HeadlessChrome:%q", fp.UserAgent)
|
||||
}
|
||||
if !strings.Contains(fp.UserAgent, "Chrome/") {
|
||||
t.Errorf("UA 看起来不是真 Chrome:%q", fp.UserAgent)
|
||||
}
|
||||
if len(fp.Languages) == 0 || !strings.HasPrefix(fp.Languages[0], "zh-CN") {
|
||||
t.Errorf("languages 应与启动的 --lang=zh-CN 一致,实得 %v", fp.Languages)
|
||||
}
|
||||
if extra.PluginsLength != realPluginsLength {
|
||||
t.Errorf("plugins.length = %d, want %d(与 reference/chromium-cdp/07 §2.2 矩阵一致)",
|
||||
extra.PluginsLength, realPluginsLength)
|
||||
}
|
||||
if !extra.HasWindowChrome {
|
||||
t.Error("window.chrome 应当存在")
|
||||
}
|
||||
|
||||
// ── 判据 ⑤:窗口内没有 bad-flags 横幅(UIA 控件树原文) ────────────────────────
|
||||
dump := uiaDumpWhenReady(t, browserPID)
|
||||
t.Logf("⑤ UI Automation 控件树(PID %d,命名节点 %d 个):\n%s",
|
||||
browserPID, countNonEmptyLines(dump), dump)
|
||||
for _, banner := range []string{
|
||||
"不受支持的命令行标记", "unsupported command-line flag", "自动测试软件正在控制",
|
||||
} {
|
||||
if strings.Contains(dump, banner) {
|
||||
t.Errorf("窗口内出现了横幅 %q:\n%s", banner, dump)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 判据 ④:同 profile 第二次启动必须复用、不起第二个进程 ─────────────────────
|
||||
second, stop2, err := l.Dial(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("第二次 Dial 应当复用已存在的端点: %v\n日志:\n%s", err, logs.String())
|
||||
}
|
||||
defer stop2()
|
||||
if second != endpoint {
|
||||
t.Errorf("第二次 Dial 交出 %q, want %q(复用必须交回同一个端点)", second, endpoint)
|
||||
}
|
||||
again := chromeProcsForProfile(t, profile, false)
|
||||
if len(again) != 1 || again[0].PID != browserPID {
|
||||
t.Errorf("第二次 Dial 不该换来新进程:before=%+v after=%+v", procs, again)
|
||||
}
|
||||
t.Logf("④ 第二次 Dial 复用成功:endpoint=%s,浏览器主进程仍是 PID %d(未起第二个)", second, browserPID)
|
||||
|
||||
// ── 收尾:关掉本次会话,并确认无残留(按唯一临时 profile 路径筛) ─────────────
|
||||
s.Close()
|
||||
left := killChromeForProfile(t, profile)
|
||||
if left != 0 {
|
||||
t.Errorf("收尾后仍按临时 profile 筛到 %d 个进程", left)
|
||||
}
|
||||
t.Logf("收尾:会话已关;按 profile %s 筛到的残留 Chrome 进程 = %d", profile, left)
|
||||
t.Logf("本次真机运行的浏览器侧日志:\n%s", logs.String())
|
||||
}
|
||||
|
||||
// realPageSignals 是页面自报的其余判据(07 §2.2 矩阵里的 plugins / window.chrome 等)。
|
||||
type realPageSignals struct {
|
||||
UserAgent string `json:"userAgent"`
|
||||
PluginsLength int `json:"pluginsLength"`
|
||||
Languages []string `json:"languages"`
|
||||
HasWindowChrome bool `json:"hasWindowChrome"`
|
||||
ChromeKeys []string `json:"chromeKeys"`
|
||||
ScreenW int `json:"screenW"`
|
||||
ScreenH int `json:"screenH"`
|
||||
ColorDepth int `json:"colorDepth"`
|
||||
Platform string `json:"platform"`
|
||||
DevicePixelRatio float64 `json:"devicePixelRatio"`
|
||||
}
|
||||
|
||||
// summary 把读数压成一行(进 t.Log,便于与 07 的矩阵逐项对照)。
|
||||
func (r realPageSignals) summary() string {
|
||||
return fmt.Sprintf("UA=%q plugins.length=%d languages=%v window.chrome=%v keys=%v "+
|
||||
"platform=%s screen=%dx%dx%d dpr=%g",
|
||||
r.UserAgent, r.PluginsLength, r.Languages, r.HasWindowChrome, r.ChromeKeys,
|
||||
r.Platform, r.ScreenW, r.ScreenH, r.ColorDepth, r.DevicePixelRatio)
|
||||
}
|
||||
|
||||
// realPageSignalsExpr 一次求值取回整组判据(口径与 07 的探针一致,多了 plugins / window.chrome)。
|
||||
const realPageSignalsExpr = `(() => ({
|
||||
userAgent: navigator.userAgent,
|
||||
pluginsLength: navigator.plugins.length,
|
||||
languages: navigator.languages,
|
||||
hasWindowChrome: typeof window.chrome !== 'undefined',
|
||||
chromeKeys: (typeof window.chrome === 'undefined') ? [] : Object.keys(window.chrome),
|
||||
screenW: screen.width,
|
||||
screenH: screen.height,
|
||||
colorDepth: screen.colorDepth,
|
||||
platform: navigator.platform,
|
||||
devicePixelRatio: window.devicePixelRatio
|
||||
}))()`
|
||||
|
||||
// readRealPageSignals 在**隔离世界**里读一次页面判据(与生产路径同一口径)。
|
||||
func readRealPageSignals(t *testing.T, ctx context.Context, s *session) realPageSignals {
|
||||
t.Helper()
|
||||
var out realPageSignals
|
||||
if err := s.evaluate(ctx, realPageSignalsExpr, &out); err != nil {
|
||||
t.Fatalf("读页面判据: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
t.Logf("页面判据(原始 JSON):%s", raw)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Windows 侧探针:进程表 / UI Automation / 收尾清理(都只按临时 profile 路径或 PID 定位) ──
|
||||
|
||||
// chromeProcsPS 列出命令行含指定 profile 的 Chrome 进程(`PID|命令行` 每行一条)。
|
||||
// includeChildren=1 时连渲染等子进程一起列(子进程 argv 里带 `--type=`)。
|
||||
const chromeProcsPS = `param([string]$Profile, [string]$IncludeChildren)
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$wantChildren = ($IncludeChildren -eq '1')
|
||||
Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.Name -like 'chrome*' -and $_.CommandLine -and $_.CommandLine.Contains($Profile) -and
|
||||
($wantChildren -or (-not $_.CommandLine.Contains('--type=')))
|
||||
} | ForEach-Object { '{0}|{1}' -f $_.ProcessId, $_.CommandLine }
|
||||
`
|
||||
|
||||
// killChromePS 按 profile 路径筛 PID 并强制结束(`$PID` 是 PowerShell 自己的进程号,
|
||||
// 必须排除 —— 它的命令行里也带着这个 profile 参数)。绝不按进程名杀。
|
||||
const killChromePS = `param([string]$Profile)
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$me = $PID
|
||||
Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.ProcessId -ne $me -and $_.Name -like 'chrome*' -and $_.CommandLine -and $_.CommandLine.Contains($Profile)
|
||||
} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
`
|
||||
|
||||
// uiaDumpPS 用 UI Automation 把该 PID 主窗口的控件树**名字**写到 `-OutPath` 指的 UTF-8 文件
|
||||
// (07 §三 的手法:纯 ASCII 脚本 + UTF-8 落盘,避开 PowerShell 5.1 按 GBK 解析脚本的问题)。
|
||||
// ⚠️ 参数不能叫 `$Out`:它会被 PowerShell 当成通用参数 `-OutVariable` 的缩写,于是我自己的
|
||||
// 参数收不到值(实测:脚本 exit 0 但文件根本没写)。
|
||||
const uiaDumpPS = `param([int]$BrowserPid, [string]$OutPath)
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
$proc = Get-Process -Id $BrowserPid
|
||||
$handle = if ($null -eq $proc) { 0 } else { $proc.MainWindowHandle }
|
||||
$text = ''
|
||||
if ($handle -eq 0) {
|
||||
$text = 'NO_WINDOW'
|
||||
} else {
|
||||
$root = [System.Windows.Automation.AutomationElement]::FromHandle($handle)
|
||||
if ($null -eq $root) { $text = 'NO_ROOT' } else {
|
||||
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
||||
$out = New-Object System.Collections.Generic.List[string]
|
||||
function Walk($el, $depth) {
|
||||
if ($null -eq $el) { return }
|
||||
if ($depth -gt 12) { return }
|
||||
try {
|
||||
$name = $el.Current.Name
|
||||
if ($name -and $name.Trim().Length -gt 0) { $out.Add((' ' * $depth) + $name.Trim()) }
|
||||
$child = $walker.GetFirstChild($el)
|
||||
while ($null -ne $child) { Walk $child ($depth + 1); $child = $walker.GetNextSibling($child) }
|
||||
} catch { }
|
||||
}
|
||||
Walk $root 0
|
||||
$text = [string]::Join([char]10, $out)
|
||||
}
|
||||
}
|
||||
[System.IO.File]::WriteAllText($OutPath, $text, (New-Object System.Text.UTF8Encoding($false)))
|
||||
`
|
||||
|
||||
// runPowerShell 把一段脚本落到临时 .ps1 再执行(args 以 `-Name value` 形式传入)。
|
||||
func runPowerShell(t *testing.T, name, script string, args ...string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name+".ps1")
|
||||
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
|
||||
t.Fatalf("写 PowerShell 脚本: %v", err)
|
||||
}
|
||||
cmdArgs := append([]string{
|
||||
"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", path,
|
||||
}, args...)
|
||||
cmd := exec.Command("powershell", cmdArgs...)
|
||||
raw, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("执行 PowerShell 脚本 %s: %v\n输出:%s", name, err, raw)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// assertProfileIsTemporary 是本文件的**安全闸**:profile 必须在系统临时目录下,
|
||||
// 免得任何一步(尤其是收尾的按路径杀进程)碰到主人的真实 profile。
|
||||
func assertProfileIsTemporary(t *testing.T, profile string) {
|
||||
t.Helper()
|
||||
tmp := strings.ToLower(strings.TrimRight(os.TempDir(), `\`))
|
||||
if !strings.HasPrefix(strings.ToLower(profile), tmp) {
|
||||
t.Fatalf("拒绝在非临时目录下跑真机用例(防误杀主人的浏览器):%s 不在 %s 下", profile, tmp)
|
||||
}
|
||||
}
|
||||
|
||||
// chromeProcsForProfile 从 Win32_Process 里筛出「命令行含这个临时 profile」的 Chrome 进程。
|
||||
// includeChildren=false 只要**浏览器主进程**(子进程 argv 里有 `--type=`)。
|
||||
func chromeProcsForProfile(t *testing.T, profile string, includeChildren bool) []realChromeArgs {
|
||||
t.Helper()
|
||||
children := "0"
|
||||
if includeChildren {
|
||||
children = "1"
|
||||
}
|
||||
out := runPowerShell(t, "chrome-procs", chromeProcsPS, "-Profile", profile, "-IncludeChildren", children)
|
||||
procs := make([]realChromeArgs, 0, 1)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
pidStr, cmdline, ok := strings.Cut(line, "|")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(pidStr))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
procs = append(procs, realChromeArgs{PID: pid, CommandLine: cmdline})
|
||||
}
|
||||
return procs
|
||||
}
|
||||
|
||||
// realChromeArgs 是从**进程表里读回**的一行进程信息(原话,不做任何改写)。
|
||||
type realChromeArgs struct {
|
||||
PID int
|
||||
CommandLine string
|
||||
}
|
||||
|
||||
// uiaDumpForPID 用 UI Automation 把这个 PID 主窗口的控件树文本 dump 出来(07 §三 的手法)。
|
||||
func uiaDumpForPID(t *testing.T, pid int) string {
|
||||
t.Helper()
|
||||
outPath := filepath.Join(t.TempDir(), "uia.txt")
|
||||
runPowerShell(t, "uia-dump", uiaDumpPS, "-BrowserPid", strconv.Itoa(pid), "-OutPath", outPath)
|
||||
raw, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读 UIA 文本 %s: %v", outPath, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// uiaDumpWhenReady 等窗口画出来再 dump(有界重试):窗口还没出现不是「没有横幅」的证据,
|
||||
// 因此读不到节点就继续等,等不到就判红(positive control)。
|
||||
func uiaDumpWhenReady(t *testing.T, pid int) string {
|
||||
t.Helper()
|
||||
var dump string
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
dump = uiaDumpForPID(t, pid)
|
||||
if countNonEmptyLines(dump) >= realUIAminNodes {
|
||||
return dump
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("UI Automation 读不到足够的控件节点(最后一次读到 %d 个命名节点,内容见下),"+
|
||||
"因此不能说「没有横幅」:\n%s", countNonEmptyLines(dump), dump)
|
||||
return ""
|
||||
}
|
||||
|
||||
// killChromeForProfile 按**唯一临时 profile 路径**筛 PID 并强制结束,返回剩下的个数。
|
||||
// 绝不按进程名杀;也绝不碰命令行里不含这个 profile 的进程。
|
||||
func killChromeForProfile(t *testing.T, profile string) int {
|
||||
t.Helper()
|
||||
runPowerShell(t, "chrome-kill", killChromePS, "-Profile", profile)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return len(chromeProcsForProfile(t, profile, true))
|
||||
}
|
||||
|
||||
// countNonEmptyLines 数文本里的非空行(UIA 控件的 positive control 用)。
|
||||
func countNonEmptyLines(s string) int {
|
||||
n := 0
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in new issue
Block a user