package browser import ( "context" "errors" "log/slog" "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 默认传的那一串), // 更不许出现 `--disable-blink-features` / `--enable-blink-features`:这两条在官方 // `kBadFlags` 名单里(reference/chromium-cdp/01),而改用**显式调试端口**后 // `navigator.webdriver` 本来就是 false(同册 07 的 g7/g8),那条 flag 已成空操作 —— // 留着只剩「每次有头启动弹一条不受支持的命令行标记」这一个副作用。 // 有人往清单里加回一条,这里立刻红。 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(9411) forbidden := []string{ "--headless", "--enable-automation", "--disable-blink-features", "--enable-blink-features", "--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、**显式**调试端口、 // 窗口尺寸、语言、免首次运行。显式端口是本片的核心改动:`--remote-debugging-port=0` 会被 // Chromium 当作 ChromeDriver 式自动化启动、主动点亮 `navigator.webdriver` // (reference/chromium-cdp/02 §4.2、07 的 g1 vs g7),因此这条用例**同时**钉住 // 「端口必须是显式数字」与「不许出现 =0」。 func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) { t.Parallel() const port = 9411 l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/prof", headless: false, viewportW: 1024, viewportH: 768} args := l.launchArgs(port) for _, want := range []string{ "--user-data-dir=/tmp/prof", "--remote-debugging-port=9411", "--window-size=1024,768", "--lang=zh-CN", "--no-first-run", "--no-default-browser-check", "--remote-allow-origins=*", "about:blank", } { if !containsArg(args, want) { t.Errorf("启动参数缺少 %q:%v", want, args) } } for _, a := range args { if strings.HasPrefix(a, "--remote-debugging-port=0") { t.Errorf("调试端口必须是显式端口(=0 会被 Chromium 当作自动化启动):%v", args) } } } // TestLaunchArgs_HeadlessOnlyWhenConfigured 钉住「无头只在显式配置时出现」: // 默认档是有头(design/28 §2),无头是面板显式降级的结果。 // // ⚠️ 无头档**必然**点亮 `AutomationControlled`(`--headless` 本身就是四条打开路径之一, // reference/chromium-cdp/02 §4.1),这是官方行为、不是我们的参数能去掉的:本片只保证 // **有头**档(默认档)不再靠那条 bad flag 去抵消它。无头档的 `webdriver` 读数**未实测** // (见本片报告「没能证明的」)。 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(9412) if len(args) == 0 || args[0] != "--headless=new" { t.Errorf("无头档的第一个参数应为 --headless=new,实得:%v", args) } if !containsArg(args, "--remote-debugging-port=9412") { t.Errorf("无头档也必须用显式调试端口:%v", args) } } // TestReadPortNumberFile 钉住「首行是端口号」的解析:我们自己的记录(一行端口)与 // Chromium 的 `DevToolsActivePort`(首行端口、次行 browser_guid)共用同一个解析。 func TestReadPortNumberFile(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, portRecordFile) if _, ok := readPortNumberFile(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 := readPortNumberFile(path); !ok || port != 54321 { t.Errorf("读到 (%d,%v), want (54321,true)", port, ok) } if err := os.WriteFile(path, []byte("54322\n"), 0o600); err != nil { t.Fatal(err) } if port, ok := readPortNumberFile(path); !ok || port != 54322 { t.Errorf("我们自己的一行记录应可解析,实得 (%d,%v)", port, ok) } if err := os.WriteFile(path, []byte("not-a-port\n"), 0o600); err != nil { t.Fatal(err) } if _, ok := readPortNumberFile(path); ok { t.Error("垃圾内容不该报 ok") } if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil { t.Fatal(err) } if _, ok := readPortNumberFile(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 目录里我们自己记的 // 调试端点(`cyrene-debug-port`)还活着 ⇒ 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() writePortRecordFile(t, dir, portOfEndpoint(t, srv.URL)) 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 钉住反面:端口记录在、但没人听 ⇒ **不认领** // (否则会连到一个死端口,比启动失败更难排查)。它同时钉住「没人听」的**分类**: // 探测要给出 ("", nil) 而不是错误 —— 这是「陈记录,可以清掉后照常启动」的那一条出路。 func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) { t.Parallel() dir := t.TempDir() writePortRecordFile(t, dir, 1) l := &localChrome{exe: "x", profileDir: dir, timeout: time.Second, log: discardLog()} endpoint, err := l.probeExistingEndpoint(context.Background()) if endpoint != "" { t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint) } if err != nil { t.Fatalf("死端口是「陈记录」而不是「坏端点」,探测不该报错: %v", err) } } // TestWaitDebugReady_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把 // 最可能的原因写进错误(profile 被占用)—— 而不是把超时白等完、只留一句「超时」。 func TestWaitDebugReady_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.waitDebugReady(context.Background(), deadPort(t), 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 } // ── 启动失败的可诊断性与分类(假浏览器见 fakebrowser_test.go) ────────────────────── // TestDial_ReportsChromeOutputAndArgvOnLaunchFailure 是本片最重要的一条:启动失败时 // **必须**把浏览器自己说的话与本次 argv 一起报进日志。真机踩过的坑正是这条的反面 —— // 窗口上跳出浏览器的抱怨,我们的日志里却只有自己这一侧的猜测("等待调试端口时被取消"), // 于是「浏览器为什么拒绝」永远查不到。 func TestDial_ReportsChromeOutputAndArgvOnLaunchFailure(t *testing.T) { t.Parallel() const complaint = "不支持的标记: --remote-allow-origins=*" log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{complaint}, ExitCode: 2}, 5*time.Second, log) _, stop, err := l.Dial(context.Background()) if stop != nil { t.Error("失败路径不该交出停止函数") } if err == nil { t.Fatal("假浏览器立刻退出时 Dial 必须报错") } if !errors.Is(err, ErrLaunch) { t.Fatalf("错误必须仍然包住 ErrLaunch(errors.Is 口径不变): %v", err) } if !strings.Contains(err.Error(), launchMarkerProcessExited) { t.Errorf("错误里应点明「进程立刻退出」这一类: %v", err) } logs := buf.String() t.Logf("实测 error=%v\n实测日志=%s", err, logs) for _, want := range []string{ complaint, // 浏览器自己说的话 l.exe, // 是哪个可执行文件 "--user-data-dir=" + l.profileDir, // 本次 argv(profile 路径不含凭据) "--remote-debugging-port=", // 端口是**显式**的(具体号码每次不同,按前缀断言) "chrome_output_bytes=", } { if !containsInLog(logs, want) { t.Errorf("失败日志里缺少 %q:\n%s", want, logs) } } } // TestDial_SuccessDoesNotSpamChromeOutput 钉住反面:**成功时不刷屏**。正常启动时 // Chrome 也会往 stderr 写常规信息,那些不该进日志;只留一个字节数(Debug,供排查用)。 // // 这里同时钉住新的就绪口径:假浏览器在**argv 给的那个显式端口**上监听并答 `/json/version` // ⇒ Dial 成功,且把**这个端口**记进了 profile 目录的端口记录(下一次启动据此复用)。 func TestDial_SuccessDoesNotSpamChromeOutput(t *testing.T) { t.Parallel() const chatter = "DevTools listening on ws://127.0.0.1:55543" log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{ Stderr: []string{chatter}, Serve: serveCDP, SleepMS: 5000, }, 5*time.Second, log) endpoint, stop, err := l.Dial(context.Background()) if err != nil { t.Fatalf("假浏览器在给定端口上服务了,Dial 应当成功: %v", err) } defer stop() port, ok := readPortRecord(l.profileDir) if !ok { t.Fatalf("成功启动后必须记下端口(否则下一次启动无法复用):endpoint=%q", endpoint) } if endpoint != endpointURL(port) { t.Errorf("endpoint = %q, want %q(必须是我们自己选并记下的那个端口)", endpoint, endpointURL(port)) } if got, dok := DebugEndpoint(l.profileDir); !dok || got != endpoint { t.Errorf("DebugEndpoint() = (%q,%v), want (%q,true)", got, dok, endpoint) } logs := buf.String() if containsInLog(logs, chatter) { t.Errorf("成功启动时不该把浏览器自己的输出打进日志(刷屏):\n%s", logs) } if !strings.Contains(logs, "browser launched") || !strings.Contains(logs, "chrome_output_bytes=") { t.Errorf("成功时只应留一条只报规模(字节数)的 Debug:\n%s", logs) } } // TestDial_ExhaustedBudgetDoesNotSpawnBrowser 钉住预算口径的**起点**:ctx 已经结束时 // **不起进程** —— 起了也只是生一个会被马上杀掉的窗口,并把「没时间了」伪装成 // 「浏览器起不来」。 func TestDial_ExhaustedBudgetDoesNotSpawnBrowser(t *testing.T) { t.Parallel() log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{ Stderr: []string{"这一行不该出现:进程根本没被拉起"}, Serve: serveCDP, SleepMS: 5000, }, 5*time.Second, log) ctx, cancel := context.WithCancel(context.Background()) cancel() _, _, err := l.Dial(ctx) if err == nil { t.Fatal("预算已耗尽时 Dial 必须报错") } t.Logf("实测错误(ctx 已取消):%v", err) if !errors.Is(err, ErrLaunch) { t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err) } if !strings.Contains(err.Error(), launchMarkerBudget) { t.Errorf("错误必须读成「预算耗尽」,实得: %v", err) } if strings.Contains(err.Error(), launchMarkerPortTimeout) { t.Errorf("预算耗尽不许被说成「端口等超时」(那是浏览器侧的问题): %v", err) } if _, ok := readPortRecord(l.profileDir); ok { t.Error("预算耗尽时不该起浏览器(端口记录被写下了)") } if n := fakeLaunches(l.profileDir); n != 0 { t.Errorf("预算耗尽时子进程不该存在(假浏览器被拉起 %d 次)", n) } if strings.Contains(buf.String(), "这一行不该出现") { t.Errorf("预算耗尽时子进程不该存在:\n%s", buf.String()) } } // TestDial_RecordsExplicitPortAndReusesItOnSecondLaunch 是本片「端口记录 + 复用」的主判据: // 第一次启动必须记下一个**显式端口**;同一个 profile 上的第二次启动必须**复用**它、**不再起 // 第二个进程**(假浏览器的启动计数是直接观察,不是猜)。 // // 为什么这条是硬要求:改用显式端口后 Chromium 不再写 `DevToolsActivePort`(reference/ // chromium-cdp/03 §3.3),若我们自己不记端口,第二次启动就会去起第二个进程 —— 而 Chromium // 会把命令委托给已在跑的那个后自己退出(真机实测),于是「复用」这条正确性路径彻底失灵。 func TestDial_RecordsExplicitPortAndReusesItOnSecondLaunch(t *testing.T) { t.Parallel() log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log) first, stop, err := l.Dial(context.Background()) if err != nil { t.Fatalf("第一次启动应当成功: %v", err) } defer stop() if n := fakeLaunches(l.profileDir); n != 1 { t.Fatalf("第一次启动应恰好起 1 个进程,实得 %d", n) } port, ok := readPortRecord(l.profileDir) if !ok || port <= 0 { t.Fatalf("必须记下显式端口,实得 port=%d ok=%v", port, ok) } if first != endpointURL(port) { t.Fatalf("endpoint = %q, want %q", first, endpointURL(port)) } second, stop2, err := l.Dial(context.Background()) if err != nil { t.Fatalf("第二次启动应当复用已存在的端点: %v", err) } defer stop2() if second != first { t.Errorf("第二次应复用它,实得 %q(第一次 %q)", second, first) } if n := fakeLaunches(l.profileDir); n != 1 { t.Errorf("第二次不该再起进程(假浏览器被拉起 %d 次),日志:\n%s", n, buf.String()) } if !strings.Contains(buf.String(), "browser debug endpoint reused") { t.Errorf("复用必须留一条 INFO:\n%s", buf.String()) } } // TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken 钉住 TOCTOU 的处置:`listen(:0)` 取号 // 与 Chromium bind 之间,端口可能被别的进程抢走 —— 这时**换一个端口重试一次**(而不是把 // 「端口被占」报成「浏览器起不来」)。假浏览器第一次抢先占住端口答非 CDP、第二次才当 CDP, // 因此这条用例同时证明「重试发生了」与「重试真的能成功」。 func TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken(t *testing.T) { t.Parallel() log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Serve: serveForeignThenCDP}, 3*time.Second, log) endpoint, stop, err := l.Dial(context.Background()) if err != nil { t.Fatalf("换端口重试之后应当成功,实得: %v\n日志:\n%s", err, buf.String()) } defer stop() if n := fakeLaunches(l.profileDir); n != 2 { t.Errorf("应当恰好重试一次(起 2 个进程),实得 %d:\n%s", n, buf.String()) } if !strings.Contains(buf.String(), "retrying with another debug port") { t.Errorf("换端口重试必须留一条 WARN(现场可查):\n%s", buf.String()) } port, ok := readPortRecord(l.profileDir) if !ok { t.Fatal("成功之后必须记下最终用的端口") } if endpoint != endpointURL(port) { t.Errorf("endpoint = %q, want %q(必须是重试后最终成功的那个端口)", endpoint, endpointURL(port)) } } // TestDial_ReportsPortTakenAfterRetryExhausted 是上一条的反面:端口**反复**被别人抢走时, // 如实报「端口在听但不是可用的 CDP 端点」(不冒充「等端口超时」,也不冒充「进程退出」), // 并且**不**留下端口记录(本次没有可用会话,留下记录会让下一次启动去探一个假端点)。 func TestDial_ReportsPortTakenAfterRetryExhausted(t *testing.T) { t.Parallel() log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Serve: serveForeign}, 300*time.Millisecond, log) _, stop, err := l.Dial(context.Background()) if stop != nil { t.Error("失败路径不该交出停止函数") } if err == nil { t.Fatal("端口一直被别人占着 ⇒ 必须如实报错") } t.Logf("实测 error=%v\n实测日志=%s", err, buf.String()) if !errors.Is(err, ErrLaunch) { t.Fatalf("仍须包住 ErrLaunch: %v", err) } if !strings.Contains(err.Error(), launchMarkerEndpointBroken) { t.Errorf("应报「端口在听但不是可用的 CDP 端点」: %v", err) } if !strings.Contains(err.Error(), "处置") { t.Errorf("应给可操作的处置建议: %v", err) } if n := fakeLaunches(l.profileDir); n != launchPortAttempts { t.Errorf("应当试满 %d 次(每次换端口),实得 %d", launchPortAttempts, n) } if _, ok := readPortRecord(l.profileDir); ok { t.Error("没有可用会话就不该留下端口记录(下一次启动会去探一个假端点)") } } // TestDial_CallerDeadlineIsReportedAsBudget 与 TestDial_ThreeLaunchFailuresAreDistinguishable // 这两条「失败分类」判据在 launchfailure_test.go(拆文件只为守住 AGENTS §10 的 500 行上限)。