- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
(把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
196 lines
7.0 KiB
Go
196 lines
7.0 KiB
Go
package browser
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestLaunchArgs_HeadfulCarriesNoAutomationTraces 是本片**最重要的一条测试**:它把
|
||
// `DevDocs/reference/browser-stealth/10 §2.1` 的黑名单钉成可执行判据 ——
|
||
// 有头路径**不许**出现任何 `--headless*`,也不许出现「自动化味道」的参数
|
||
// (`--enable-automation` 以及 Playwright/Puppeteer 默认传的那一串)。
|
||
// 有人往清单里加回一条,这里立刻红。
|
||
func TestLaunchArgs_HeadfulCarriesNoAutomationTraces(t *testing.T) {
|
||
t.Parallel()
|
||
l := &localChrome{exe: "chrome.exe", profileDir: `C:\tmp\p`, headless: false, viewportW: 1280, viewportH: 800}
|
||
args := l.launchArgs()
|
||
|
||
forbidden := []string{
|
||
"--headless",
|
||
"--enable-automation",
|
||
"--disable-extensions",
|
||
"--disable-default-apps",
|
||
"--disable-component-extensions-with-background-pages",
|
||
"--disable-component-update",
|
||
"--disable-popup-blocking",
|
||
}
|
||
for _, bad := range forbidden {
|
||
for _, a := range args {
|
||
if strings.HasPrefix(a, bad) {
|
||
t.Errorf("有头启动参数里出现了不许传的 %q:%v", bad, args)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestLaunchArgs_RequiredFlagsPresent 钉住「必须传」的那几条:profile、随机回环调试
|
||
// 端口、窗口尺寸、语言、免首次运行、以及 AutomationControlled 兜底防线。
|
||
func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) {
|
||
t.Parallel()
|
||
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/prof", headless: false, viewportW: 1024, viewportH: 768}
|
||
args := l.launchArgs()
|
||
for _, want := range []string{
|
||
"--user-data-dir=/tmp/prof",
|
||
"--remote-debugging-port=0",
|
||
"--window-size=1024,768",
|
||
"--lang=zh-CN",
|
||
"--no-first-run",
|
||
"--no-default-browser-check",
|
||
"--disable-blink-features=AutomationControlled",
|
||
"--remote-allow-origins=*",
|
||
"about:blank",
|
||
} {
|
||
if !containsArg(args, want) {
|
||
t.Errorf("启动参数缺少 %q:%v", want, args)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestLaunchArgs_HeadlessOnlyWhenConfigured 钉住「无头只在显式配置时出现」:
|
||
// 默认档是有头(design/28 §2),无头是面板显式降级的结果。
|
||
func TestLaunchArgs_HeadlessOnlyWhenConfigured(t *testing.T) {
|
||
t.Parallel()
|
||
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/p", headless: true, viewportW: 800, viewportH: 600}
|
||
args := l.launchArgs()
|
||
if len(args) == 0 || args[0] != "--headless=new" {
|
||
t.Errorf("无头档的第一个参数应为 --headless=new,实得:%v", args)
|
||
}
|
||
if !containsArg(args, "--disable-blink-features=AutomationControlled") {
|
||
t.Error("无头档也必须保留 AutomationControlled 防线")
|
||
}
|
||
}
|
||
|
||
// TestReadDevToolsPort 钉住端口文件解析:正常两行、只有一行、缺文件、垃圾内容。
|
||
func TestReadDevToolsPort(t *testing.T) {
|
||
t.Parallel()
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, devToolsPortFile)
|
||
|
||
if _, ok := readDevToolsPort(path); ok {
|
||
t.Error("文件不存在时不该报 ok")
|
||
}
|
||
if err := os.WriteFile(path, []byte("54321\nbrowser-guid-xyz\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if port, ok := readDevToolsPort(path); !ok || port != 54321 {
|
||
t.Errorf("读到 (%d,%v), want (54321,true)", port, ok)
|
||
}
|
||
if err := os.WriteFile(path, []byte("not-a-port\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, ok := readDevToolsPort(path); ok {
|
||
t.Error("垃圾内容不该报 ok")
|
||
}
|
||
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, ok := readDevToolsPort(path); ok {
|
||
t.Error("端口 0 不该报 ok")
|
||
}
|
||
}
|
||
|
||
// TestLocalChromeDescribe 钉住来源描述如实反映有头/无头(日志与工具结果都用它)。
|
||
func TestLocalChromeDescribe(t *testing.T) {
|
||
t.Parallel()
|
||
headful := &localChrome{exe: "chrome.exe"}
|
||
if got := headful.Describe(); !strings.Contains(got, "headful") {
|
||
t.Errorf("Describe() = %q,应含 headful", got)
|
||
}
|
||
headless := &localChrome{exe: "chrome.exe", headless: true}
|
||
if got := headless.Describe(); !strings.Contains(got, "headless") {
|
||
t.Errorf("Describe() = %q,应含 headless", got)
|
||
}
|
||
}
|
||
|
||
// TestLocalChromeAdoptsLiveEndpoint 钉住「认领已在跑的实例」:profile 目录里的
|
||
// DevToolsActivePort 还活着 ⇒ Dial 直接交出那个端点、**不启动任何进程**。
|
||
// 这不是优化:同一 profile 起第二个实例时 Chromium 会委托给已在跑的那个然后自己退出,
|
||
// 新进程永远不写端口文件 ⇒ 不做认领,功能会被一个残留实例卡死。
|
||
func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
|
||
t.Parallel()
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/json/version" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
_, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0"}`))
|
||
}))
|
||
defer srv.Close()
|
||
port := strings.TrimPrefix(srv.URL, "http://127.0.0.1:")
|
||
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte(port+"\nGUID\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
l := &localChrome{exe: "definitely-not-a-browser.exe", profileDir: dir, timeout: time.Second, log: discardLog()}
|
||
endpoint, stop, err := l.Dial(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("应当认领活着端点,实得: %v", err)
|
||
}
|
||
if endpoint != "http://127.0.0.1:"+port {
|
||
t.Errorf("endpoint = %q", endpoint)
|
||
}
|
||
stop() // 认领来的实例不归我们杀:停止函数必须是空操作(调用不 panic 即通过)
|
||
}
|
||
|
||
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口文件在、但没人听 ⇒ **不认领**
|
||
// (否则会连到一个死端口,比启动失败更难排查)。
|
||
func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) {
|
||
t.Parallel()
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte("1\nGUID\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
l := &localChrome{exe: "x", profileDir: dir, timeout: time.Second, log: discardLog()}
|
||
if endpoint, ok := l.adoptExisting(context.Background()); ok {
|
||
t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint)
|
||
}
|
||
}
|
||
|
||
// TestWaitDevToolsPort_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
|
||
// 最可能的原因写进错误(profile 被占用)—— 而不是把超时白等完、只留一句「超时」。
|
||
func TestWaitDevToolsPort_ReportsEarlyExit(t *testing.T) {
|
||
t.Parallel()
|
||
dir := t.TempDir()
|
||
l := &localChrome{profileDir: dir, timeout: 10 * time.Second, log: discardLog()}
|
||
exited := make(chan struct{})
|
||
close(exited)
|
||
start := time.Now()
|
||
_, err := l.waitDevToolsPort(context.Background(), exited)
|
||
if err == nil {
|
||
t.Fatal("进程退出后应当报错")
|
||
}
|
||
if elapsed := time.Since(start); elapsed > time.Second {
|
||
t.Errorf("等了 %v 才报错,应当立刻返回", elapsed)
|
||
}
|
||
if !strings.Contains(err.Error(), "profile") {
|
||
t.Errorf("错误里应点明 profile 占用这个最常见原因: %v", err)
|
||
}
|
||
}
|
||
|
||
// containsArg 报告参数表里是否有这一条(逐字相等)。
|
||
func containsArg(args []string, want string) bool {
|
||
for _, a := range args {
|
||
if a == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|