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

402 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}