package browser // 本文件盯住「起进程之前的三条判定」(实现见 adopt.go): // // ① 端点活着 ⇒ **复用**,不起第二个进程; // ② 端口记录在、端口没人听 ⇒ 清掉陈记录后照常启动; // ③ 端口在听却握不上手 ⇒ 如实报错(带处置建议),不起进程; // ④ 被占用且拿不到端点 ⇒ 情况②之外的「真被占用」:如实报 + 带上 Chrome 自己的原话。 // // 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**两重观察** —— // ① **诱饵**:这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器 // (fakebrowser_test.go 的重跑自己范式),真走了启动路径,喊声会进日志、Dial 也会失败; // ② **启动计数**:假浏览器每次被拉起都会往 profile 目录追一行(`fakeLaunches`),可以直接 // 数出「起了几次」。用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒ // 判据①立刻变红。) // // 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的端口 // 记录,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令委托给了已在跑 // 的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。 import ( "context" "errors" "log/slog" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" ) // fakeBrowserLure 是诱饵:假浏览器一旦被拉起就喊这句,用例据此判定「起了第二个进程」。 const fakeBrowserLure = "假浏览器被拉起了 —— 这一档不该有第二个进程" // chromeSaysSessionOpen 是真 Chrome 把命令委托给现存实例时写出的那句 stderr 原话。 const chromeSaysSessionOpen = "正在现有的浏览器会话中打开。" // deadPort 返回一个「刚刚还活着、现在没人听」的回环端口号:用来造**陈**的端口记录。 func deadPort(t *testing.T) int { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("取空闲端口: %v", err) } port := ln.Addr().(*net.TCPAddr).Port if err := ln.Close(); err != nil { t.Fatalf("释放端口: %v", err) } return port } // portOfEndpoint 取 `http://127.0.0.1:PORT` 里的端口号(写端口记录用)。 func portOfEndpoint(t *testing.T, endpoint string) int { t.Helper() port, err := strconv.Atoi(strings.TrimPrefix(endpoint, "http://127.0.0.1:")) if err != nil { t.Fatalf("从 %q 取端口: %v", endpoint, err) } return port } // portFileExists 报告**我们自己的**端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。 func portFileExists(dir string) bool { _, err := os.Stat(portRecordPath(dir)) return err == nil } // writePortRecordFile 写一条我们自己的端口记录(实现见 portrecord.go;格式:一行端口号)。 func writePortRecordFile(t *testing.T, dir string, port int) { t.Helper() if err := writePortRecord(dir, port); err != nil { t.Fatalf("写端口记录: %v", err) } } // writeLegacyDevToolsPortFile 写一条**老格式**的端口记录(Chromium 在 `port==0` 时写的 // `DevToolsActivePort`:首行端口、次行 browser_guid)—— 用它钉住「换二进制后上一版起的浏览器 // 仍能被复用」这条兼容路径。 func writeLegacyDevToolsPortFile(t *testing.T, dir string, port int) { t.Helper() path := filepath.Join(dir, legacyDevToolsPortFile) if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\nGUID\n"), 0o600); err != nil { t.Fatalf("写老格式端口记录: %v", err) } } // TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 是判据①:profile 里记的调试端点 // 在听、`/json/version` 也答得上来 ⇒ **连它**,不出第二个进程。 // // 复用的必要性不只是省一次启动:同 profile 上起第二个实例时 Chromium 会把命令委托出去后自己 // 退出,新进程永远不写端口记录 ⇒ 不复用就等于这一档不可用(详见 adopt.go 的文件头)。 func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) { t.Parallel() cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil }) log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log) writePortRecordFile(t, l.profileDir, portOfEndpoint(t, cdp.base)) endpoint, stop, err := l.Dial(context.Background()) if err != nil { t.Fatalf("活着且握手成功的端点应当被复用,实得错误: %v", err) } defer stop() if endpoint != cdp.base { t.Errorf("endpoint = %q, want %q(必须是复用来的那个端点)", endpoint, cdp.base) } logs := buf.String() t.Logf("实测日志:\n%s", logs) if containsInLog(logs, fakeBrowserLure) { t.Errorf("起了第二个进程(诱饵被拉起)—— 复用这一支没生效:\n%s", logs) } if !strings.Contains(logs, "browser debug endpoint reused") { t.Errorf("复用必须留一条 INFO(说明复用了已存在的调试端点):\n%s", logs) } if !strings.Contains(logs, "source="+logSource) { t.Errorf("这条 INFO 必须带来源(AGENTS §10 禁无来源裸日志):\n%s", logs) } if n := fakeLaunches(l.profileDir); n != 0 { t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次)", n) } stop() // 复用来的实例不归我们杀:停止函数必须是空操作(能调用即通过) } // TestDial_ReusesLegacyDevToolsActivePort 钉住一条**兼容**路径:老版二进制(那时我们传的是 // `--remote-debugging-port=0`)在 profile 里留下的 `DevToolsActivePort` 若还活着,也必须被认领。 // // 为什么值得单独钉:换二进制时上一版起的浏览器可能还在跑(本项目的后端常驻、浏览器按需回收)。 // 若不认那个文件,新二进制会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」, // 于是功能一直不可用,直到那个浏览器自己空闲回收。 func TestDial_ReusesLegacyDevToolsActivePort(t *testing.T) { t.Parallel() cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil }) log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log) writeLegacyDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, cdp.base)) endpoint, stop, err := l.Dial(context.Background()) if err != nil { t.Fatalf("老格式记录指向的活端点也该被复用,实得: %v", err) } defer stop() if endpoint != cdp.base { t.Errorf("endpoint = %q, want %q", endpoint, cdp.base) } if n := fakeLaunches(l.profileDir); n != 0 { t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次):\n%s", n, buf.String()) } } // TestDial_ClearsStaleLegacyRecord 补上「陈记录清理」的另一半:**老格式**记录指向的端口已经 // 没人听时,它也必须被清掉(否则下一次启动还会去探一个死端点),且不挡住正常启动。 func TestDial_ClearsStaleLegacyRecord(t *testing.T) { t.Parallel() log, _ := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log) writeLegacyDevToolsPortFile(t, l.profileDir, deadPort(t)) endpoint, stop, err := l.Dial(context.Background()) if err != nil { t.Fatalf("陈的老格式记录不该挡住正常启动: %v", err) } defer stop() port, ok := readPortRecord(l.profileDir) if !ok || endpoint != endpointURL(port) { t.Fatalf("成功之后应当记下本次用的端口:endpoint=%q port=%d ok=%v", endpoint, port, ok) } if _, err := os.Stat(filepath.Join(l.profileDir, legacyDevToolsPortFile)); err == nil { t.Error("陈的老格式记录应当已被清掉(留着它下次还会去探那个死端口)") } } // TestDial_ClearsStalePortFileThenLaunches 是判据②:端口记录在、但端口已经没人听 ⇒ 那是 // **陈记录**,清掉后照常启动。两个子用例各钉一半: // // (1) 清掉之后,本次就绪判定认**新进程实际服务的**那个端口; // (2) 清掉是**真清**:新进程从不就绪时,Dial 必须报「等端口就绪超时」—— 而不是把陈端口当成 // 本次结果稀里糊涂地成功(那正是「不清陈记录」会造成的伪装)。 func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) { t.Parallel() t.Run("以新进程实际服务的端口为准", func(t *testing.T) { t.Parallel() log, _ := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log) stale := deadPort(t) writePortRecordFile(t, l.profileDir, stale) endpoint, stop, err := l.Dial(context.Background()) if err != nil { t.Fatalf("陈记录不该挡住正常启动: %v", err) } defer stop() port, ok := readPortRecord(l.profileDir) if !ok { t.Fatal("成功之后必须记下本次用的端口") } if endpoint != endpointURL(port) { t.Errorf("endpoint = %q, want %q", endpoint, endpointURL(port)) } if port == stale { t.Errorf("陈端口(%d)被当成本次结果了", stale) } }) t.Run("陈端口不会被当成本次结果", func(t *testing.T) { t.Parallel() log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{ Stderr: []string{"起来了,但从不答 CDP"}, SleepMS: 3000, }, 300*time.Millisecond, log) stale := deadPort(t) writePortRecordFile(t, l.profileDir, stale) endpoint, _, err := l.Dial(context.Background()) if err == nil { t.Fatalf("陈记录被清掉、新进程又从不就绪 ⇒ 必须报错;实得 endpoint=%q", endpoint) } t.Logf("实测 error=%v\n实测日志=%s", err, buf.String()) if !errors.Is(err, ErrLaunch) { t.Fatalf("仍须包住 ErrLaunch: %v", err) } if !strings.Contains(err.Error(), launchMarkerPortTimeout) { t.Errorf("应当读成「等调试端口就绪超时」: %v", err) } if endpoint == endpointURL(stale) { t.Errorf("把陈端口当成本次结果了(endpoint=%q)", endpoint) } if portFileExists(l.profileDir) { t.Error("陈的端口记录应当已被清掉(否则下次还会读到它)") } }) } // TestDial_ReportsLiveButUnusableEndpoint 是判据③:端口在听、但 `/json/version` 握不上手 ⇒ // **如实报错**,而且**不许静默忽略后另起一个进程**(起出来的那个只会委托出去再退出,把原因 // 糊成一句「启动失败」)。三种「在听但不是 CDP」的形态各钉一格。 func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) { t.Parallel() cases := []struct { name string handler http.HandlerFunc }{ {"响应不是 JSON", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("我不是 CDP")) }}, {"JSON 但没有 Browser 字段", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"hello":"world"}`)) }}, {"HTTP 503", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() srv := httptest.NewServer(tc.handler) defer srv.Close() log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 3*time.Second, log) writePortRecordFile(t, l.profileDir, portOfEndpoint(t, srv.URL)) _, 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) } for _, want := range []string{"处置", portRecordFile, l.profileDir} { if !containsInLog(err.Error(), want) { t.Errorf("可操作的处置建议里缺少 %q: %v", want, err) } } if containsInLog(buf.String(), fakeBrowserLure) { t.Errorf("不许静默忽略后另起一个进程(诱饵被拉起):\n%s", buf.String()) } if !portFileExists(l.profileDir) { t.Error("握不上手时不该动端口记录:它是现场证据,也是复用判定的唯一依据") } }) } } // TestDial_ReportsOccupiedProfileWithChromeOutput 是判据④,也是本片要修的那个「必然等不到 // 端口」的场景在**我们没法复用**时的出口:新进程 exit 0(真机就是这样)、不写端口记录, // Chrome 自己只在 stderr 说一句「正在现有的浏览器会话中打开。」。 // // 要求:立刻报(不把预算干等完)、说清是哪一类(launchMarkerProcessExited)、给可操作建议、 // 并且把 **Chrome 自己的原话** 带进**返回给调用方**的错误里(用户侧的工具结果来自这条错误, // 只留在日志里等于用户看不到)。 func TestDial_ReportsOccupiedProfileWithChromeOutput(t *testing.T) { t.Parallel() log, buf := newCapturedLog(slog.LevelDebug) l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{chromeSaysSessionOpen}, ExitCode: 0}, 5*time.Second, log) start := time.Now() _, stop, err := l.Dial(context.Background()) elapsed := time.Since(start) if stop != nil { t.Error("失败路径不该交出停止函数") } if err == nil { t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错(真机就是这个形态)") } logs := buf.String() t.Logf("实测 error=%v\n实测耗时=%v\n实测日志=%s", err, elapsed, logs) if elapsed > 2*time.Second { t.Errorf("应当立刻报,不把配置的 timeout 白等完:等了 %v", elapsed) } if !errors.Is(err, ErrLaunch) { t.Fatalf("仍须包住 ErrLaunch: %v", err) } if !strings.Contains(err.Error(), launchMarkerProcessExited) { t.Errorf("错误里应点明「进程立刻退出」这一类: %v", err) } if !strings.Contains(err.Error(), chromeSaysSessionOpen) { t.Errorf("错误里必须带上 Chrome 自己的原话(用户侧只看得到这条错误): %v", err) } if !strings.Contains(err.Error(), "浏览器输出:") { t.Errorf("原话应当以「浏览器输出:」引出(对应 withChromeOutput): %v", err) } for _, want := range []string{"处置", "关掉那个浏览器窗口", "绝不杀别人的浏览器", l.profileDir} { if !containsInLog(err.Error(), want) { t.Errorf("可操作的处置建议里缺少 %q: %v", want, err) } } for _, want := range []string{ "--user-data-dir=" + l.profileDir, // 现场可复现:本次 argv "chrome_output_bytes=", // 浏览器说了多少 chromeSaysSessionOpen, // 日志里也要有原话 } { if !containsInLog(logs, want) { t.Errorf("失败日志里缺少 %q:\n%s", want, logs) } } }