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
@@ -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 行上限)。
|
||||
Reference in new issue
Block a user