Files
AskaEth 6fe68ecfac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 整链路(未碰)。
2026-10-07 11:18:25 +08:00

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))
}