依据 `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 整链路(未碰)。
261 lines
10 KiB
Go
261 lines
10 KiB
Go
package browser
|
|
|
|
// 本文件是**假浏览器**:启动路径的测试必须真起一个子进程,才能证明
|
|
// - 子进程的 stdout/stderr 被接住了(失败时上报、成功时不刷屏);
|
|
// - 三种失败情形(进程立刻退出 / 端点没就绪 / 预算没了)分辨得开;
|
|
// - 「就绪」判的是**我们自己选的那个端口答不答 CDP**(显式端口下 Chromium 不再写
|
|
// DevToolsActivePort,见 portrecord.go),因此假浏览器要真的在 argv 给的端口上监听。
|
|
//
|
|
// 手法是 Go 里既有的「重跑自己」范式(stdlib `os/exec` 的 testhelper 同款,本仓
|
|
// `internal/capability/plugin/manager_test.go` 的 MCP stdio 假 server 也用它):
|
|
// 被当成浏览器拉起的可执行文件就是**测试二进制自己**(`os.Args[0]`),行为由 profile
|
|
// 目录里的一份脚本文件给出 —— 脚本落在 `Dial` 传 `--user-data-dir` 的那个目录里,
|
|
// 因此每个用例各说各话、可以并行。
|
|
//
|
|
// `TestMain` 是接住它的唯一位置:子进程的 argv 是**浏览器参数**(没有 `-test.run`),
|
|
// 到不了任何 Test 函数;不接住就会递归跑整个测试套件。
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
)
|
|
|
|
const (
|
|
// fakeBrowserScriptFile 是假浏览器脚本的文件名(落在 profile 目录里)。
|
|
fakeBrowserScriptFile = "fake-browser.json"
|
|
// 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 是一次「假启动」的行为脚本(字段零值 = 不做那件事)。
|
|
type fakeBrowserScript struct {
|
|
// Stdout / Stderr 是要写出去的若干行(模拟浏览器自己说的话)。
|
|
Stdout []string `json:"stdout"`
|
|
Stderr []string `json:"stderr"`
|
|
// ExitCode 是退出码。
|
|
ExitCode int `json:"exitCode"`
|
|
// 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.Args[1:]))
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// fakeBrowserProfileDir 从 argv 里取出 profile 目录(找不到 ⇒ 这是普通的测试运行)。
|
|
func fakeBrowserProfileDir(args []string) (string, bool) {
|
|
for _, a := range args {
|
|
if dir, ok := strings.CutPrefix(a, fakeBrowserUserDataDirFlag); ok {
|
|
return dir, true
|
|
}
|
|
}
|
|
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, args []string) int {
|
|
raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserScriptFile))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
|
|
return fakeBrowserNoScriptExit
|
|
}
|
|
var script fakeBrowserScript
|
|
if err := json.Unmarshal(raw, &script); err != nil {
|
|
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.Serve != "" {
|
|
if err := startFakeCDPServer(fakeBrowserDebugPort(args), serveKind(script.Serve, launchNo)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
|
|
return fakeBrowserNoScriptExit
|
|
}
|
|
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 {
|
|
t.Helper()
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("取测试二进制路径: %v", err)
|
|
}
|
|
dir := t.TempDir()
|
|
raw, err := json.Marshal(script)
|
|
if err != nil {
|
|
t.Fatalf("编码假浏览器脚本: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, fakeBrowserScriptFile), raw, 0o600); err != nil {
|
|
t.Fatalf("写假浏览器脚本: %v", err)
|
|
}
|
|
return &localChrome{
|
|
exe: exe, profileDir: dir,
|
|
viewportW: 1280, viewportH: 800,
|
|
timeout: timeout, log: log,
|
|
}
|
|
}
|
|
|
|
// newCapturedLog 造一个把日志收进缓冲的 logger(带来源标签,与 discardLog 同源)。
|
|
func newCapturedLog(level slog.Level) (*slog.Logger, *bytes.Buffer) {
|
|
var buf bytes.Buffer
|
|
base := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: level}))
|
|
return logger.WithSource(base, logSource), &buf
|
|
}
|
|
|
|
// escapedInLog 把字符串按 slog TextHandler 的转义规则处理:值里含空格/引号时它整段走
|
|
// `strconv.Quote`(反斜杠变双反斜杠);不含时原样输出。断言前把两种形态都认,免得
|
|
// 「同一个路径在 exe= 里是单反斜杠、在 argv= 里是双反斜杠」这种细节把用例判红。
|
|
func escapedInLog(s string) string { return strings.ReplaceAll(s, `\`, `\\`) }
|
|
|
|
// containsInLog 报告日志里是否出现了这段文字(原样或 slog 的引号化形态)。
|
|
func containsInLog(logs, want string) bool {
|
|
return strings.Contains(logs, want) || strings.Contains(logs, escapedInLog(want))
|
|
}
|