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 }