package browser // 本文件是**假浏览器**:启动路径的测试必须真起一个子进程,才能证明 // - 子进程的 stdout/stderr 被接住了(失败时上报、成功时不刷屏); // - 三种失败情形(进程立刻退出 / 端点没就绪 / 预算没了)分辨得开; // - 「就绪」判的是**我们自己选的那个端口答不答 CDP**(显式端口下 Chromium 不再写 // DevToolsActivePort,见 portrecord.go),因此假浏览器要真的在 argv 给的端口上监听。 // // 手法是 Go 里既有的「重跑自己」范式(stdlib `os/exec` 的 testhelper 同款,本仓 // `internal/capability/plugin/manager_test.go` 的 MCP stdio 假 server 也用它): // 被当成浏览器拉起的可执行文件就是**测试二进制自己**(`os.Args[0]`),行为由 profile // 目录里的一份脚本文件给出 —— 脚本落在 `Dial` 传 `--user-data-dir` 的那个目录里, // 因此每个用例各说各话、可以并行。 // // `TestMain` 是接住它的唯一位置:子进程的 argv 是**浏览器参数**(没有 `-test.run`), // 到不了任何 Test 函数;不接住就会递归跑整个测试套件。 import ( "bytes" "encoding/json" "fmt" "log/slog" "net" "net/http" "os" "path/filepath" "strconv" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) const ( // fakeBrowserScriptFile 是假浏览器脚本的文件名(落在 profile 目录里)。 fakeBrowserScriptFile = "fake-browser.json" // fakeBrowserUserDataDirFlag 是脚本的定位方式:profile 目录就是 argv 里 // `--user-data-dir=` 的值(真浏览器的同一个参数)。 fakeBrowserUserDataDirFlag = "--user-data-dir=" // fakeBrowserDebugPortFlag 是假浏览器的调试端口来源(与真浏览器的参数同名); // 显式端口是我们自己选的,假浏览器照它监听才算「就绪」。 fakeBrowserDebugPortFlag = "--remote-debugging-port=" // fakeBrowserLaunchLogFile 记录**每次**假浏览器被拉起(一行一次)。它是「有没有起 // 第二个进程」这一判据的**直接观察**:复用与「预算耗尽不 spawn」两类用例都数它。 fakeBrowserLaunchLogFile = "fake-browser-launches" // fakeBrowserNoScriptExit 是「argv 像浏览器、但没有脚本」时的退出码:宁可表现得像 // 一个立刻退出的浏览器,也不递归跑整个测试套件(那是测试基建事故,不是用例失败)。 fakeBrowserNoScriptExit = 7 // fakeBrowserServeIdle 是「要在端口上服务」时的默认存活时长:子进程必须活到用例 // 判定完(真浏览器是被 Kill 才退的),但不能无限活着 —— 无限活着会漏成孤儿进程。 fakeBrowserServeIdle = 30 * time.Second ) // 假浏览器在调试端口上的三种做派(见 fakeBrowserScript.Serve)。 const ( // serveCDP:监听并答合法的 `/json/version` —— 这就是「真浏览器已就绪」。 serveCDP = "cdp" // serveForeign:监听但答一个**不是 CDP** 的响应 —— 模拟「我们选的端口被别人占了」 // (TOCTOU 抢占),用于验证换端口重试。 serveForeign = "foreign" // serveForeignThenCDP:第一次抢先占住端口、第二次才当 CDP —— 验证「重试之后真的成功」。 serveForeignThenCDP = "foreign-then-cdp" ) // fakeBrowserScript 是一次「假启动」的行为脚本(字段零值 = 不做那件事)。 type fakeBrowserScript struct { // Stdout / Stderr 是要写出去的若干行(模拟浏览器自己说的话)。 Stdout []string `json:"stdout"` Stderr []string `json:"stderr"` // ExitCode 是退出码。 ExitCode int `json:"exitCode"` // Serve 决定它在 `--remote-debugging-port=` 那个端口上做什么(见上面三个常量); // 空串 = **不监听**(模拟「起了但从不就绪」)。 Serve string `json:"serve"` // SleepMS > 0 ⇒ 之后继续活着(模拟浏览器常驻):由 Dial 的停止函数或超时路径 Kill。 // Serve 非空时即使它为 0 也会活 fakeBrowserServeIdle(否则子进程可能在判定前就退了)。 SleepMS int `json:"sleepMs"` } // TestMain 让测试二进制可以**兼任**假浏览器。 func TestMain(m *testing.M) { if dir, ok := fakeBrowserProfileDir(os.Args[1:]); ok { os.Exit(runFakeBrowser(dir, os.Args[1:])) } os.Exit(m.Run()) } // fakeBrowserProfileDir 从 argv 里取出 profile 目录(找不到 ⇒ 这是普通的测试运行)。 func fakeBrowserProfileDir(args []string) (string, bool) { for _, a := range args { if dir, ok := strings.CutPrefix(a, fakeBrowserUserDataDirFlag); ok { return dir, true } } return "", false } // fakeBrowserDebugPort 从 argv 里取出调试端口(找不到 / 不合法 ⇒ 0,表示「不监听」)。 func fakeBrowserDebugPort(args []string) int { for _, a := range args { raw, ok := strings.CutPrefix(a, fakeBrowserDebugPortFlag) if !ok { continue } port, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil || port <= 0 || port > 65535 { return 0 } return port } return 0 } // runFakeBrowser 按脚本演一次假浏览器,返回退出码。 func runFakeBrowser(profileDir string, args []string) int { raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserScriptFile)) if err != nil { fmt.Fprintf(os.Stderr, "fake browser: %v\n", err) return fakeBrowserNoScriptExit } var script fakeBrowserScript if err := json.Unmarshal(raw, &script); err != nil { fmt.Fprintf(os.Stderr, "fake browser: %v\n", err) return fakeBrowserNoScriptExit } launchNo := recordFakeLaunch(profileDir) for _, line := range script.Stdout { fmt.Fprintln(os.Stdout, line) } for _, line := range script.Stderr { fmt.Fprintln(os.Stderr, line) } if script.Serve != "" { if err := startFakeCDPServer(fakeBrowserDebugPort(args), serveKind(script.Serve, launchNo)); err != nil { fmt.Fprintf(os.Stderr, "fake browser: %v\n", err) return fakeBrowserNoScriptExit } time.Sleep(fakeBrowserServeIdle) } else if script.SleepMS > 0 { time.Sleep(time.Duration(script.SleepMS) * time.Millisecond) } return script.ExitCode } // serveKind 把脚本里的做派与**这是第几次被拉起**合起来算出本次的实际做派: // `foreign-then-cdp` 只在第一次抢端口,第二次起老老实实当 CDP —— 于是「换端口重试之后 // 成功」这条路径可被断言,而不是只断言「重试过」。 func serveKind(mode string, launchNo int) string { if mode == serveForeignThenCDP && launchNo > 1 { return serveCDP } if mode == serveForeignThenCDP { return serveForeign } return mode } // recordFakeLaunch 追加一行「我起来了」,返回这是第几次(1 起)。计数落在 profile 目录里, // 因此每个用例各数各的。 func recordFakeLaunch(profileDir string) int { path := filepath.Join(profileDir, fakeBrowserLaunchLogFile) raw, _ := os.ReadFile(path) n := fakeLaunchLines(string(raw)) n++ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return n } defer func() { _ = f.Close() }() _, _ = fmt.Fprintf(f, "launch %d\n", n) return n } // fakeLaunchLines 数启动记录的行数(一行一次;空行不算)。 func fakeLaunchLines(raw string) int { n := 0 for _, line := range strings.Split(raw, "\n") { if strings.TrimSpace(line) != "" { n++ } } return n } // startFakeCDPServer 在 port 上起一个假 CDP HTTP 端点(在后台协程里服务到进程被杀)。 // port ≤ 0(argv 里没有可用端口)不服务:就绪判定因此不会成功,如实走失败路径。 func startFakeCDPServer(port int, mode string) error { if port <= 0 { return nil } mux := http.NewServeMux() if mode == serveForeign { // 「端口在听但不是 CDP」:任何路径都答一段不是 CDP 的正文。 mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("我不是 CDP,只是占着这个端口")) }) } else { mux.HandleFunc("/json/version", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0","webSocketDebuggerUrl":"ws://127.0.0.1:` + strconv.Itoa(port) + `/devtools/browser/fake"}`)) }) } ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) if err != nil { return fmt.Errorf("监听调试端口 %d: %w", port, err) } srv := &http.Server{Handler: mux} go func() { _ = srv.Serve(ln) }() return nil } // fakeLaunches 数这个 profile 上假浏览器被拉起过几次(0 = 没起过任何进程)。 func fakeLaunches(profileDir string) int { raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserLaunchLogFile)) if err != nil { return 0 } return fakeLaunchLines(string(raw)) } // newFakeChrome 造一个「可执行文件 = 测试二进制自己」的 localChrome,并把脚本写进 // profile 目录(`Dial` 会把它当 `--user-data-dir` 传下来)。 func newFakeChrome(t *testing.T, script fakeBrowserScript, timeout time.Duration, log *slog.Logger) *localChrome { t.Helper() exe, err := os.Executable() if err != nil { t.Fatalf("取测试二进制路径: %v", err) } dir := t.TempDir() raw, err := json.Marshal(script) if err != nil { t.Fatalf("编码假浏览器脚本: %v", err) } if err := os.WriteFile(filepath.Join(dir, fakeBrowserScriptFile), raw, 0o600); err != nil { t.Fatalf("写假浏览器脚本: %v", err) } return &localChrome{ exe: exe, profileDir: dir, viewportW: 1280, viewportH: 800, timeout: timeout, log: log, } } // newCapturedLog 造一个把日志收进缓冲的 logger(带来源标签,与 discardLog 同源)。 func newCapturedLog(level slog.Level) (*slog.Logger, *bytes.Buffer) { var buf bytes.Buffer base := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: level})) return logger.WithSource(base, logSource), &buf } // escapedInLog 把字符串按 slog TextHandler 的转义规则处理:值里含空格/引号时它整段走 // `strconv.Quote`(反斜杠变双反斜杠);不含时原样输出。断言前把两种形态都认,免得 // 「同一个路径在 exe= 里是单反斜杠、在 argv= 里是双反斜杠」这种细节把用例判红。 func escapedInLog(s string) string { return strings.ReplaceAll(s, `\`, `\\`) } // containsInLog 报告日志里是否出现了这段文字(原样或 slog 的引号化形态)。 func containsInLog(logs, want string) bool { return strings.Contains(logs, want) || strings.Contains(logs, escapedInLog(want)) }