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 }