依据 `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 整链路(未碰)。
110 lines
4.2 KiB
Go
110 lines
4.2 KiB
Go
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
|
||
})
|
||
}
|
||
}
|