依据 `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 整链路(未碰)。
402 lines
17 KiB
Go
402 lines
17 KiB
Go
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
|
||
}
|