package browser // 本文件是**假浏览器**:启动路径的测试必须真起一个子进程,才能证明 // - 子进程的 stdout/stderr 被接住了(失败时上报、成功时不刷屏); // - 三种失败情形(进程立刻退出 / 端口没出来 / 预算没了)分辨得开。 // // 手法是 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" "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=" // fakeBrowserNoScriptExit 是「argv 像浏览器、但没有脚本」时的退出码:宁可表现得像 // 一个立刻退出的浏览器,也不递归跑测试套件(那是测试基建事故,不是用例失败)。 fakeBrowserNoScriptExit = 7 ) // fakeBrowserScript 是一次「假启动」的行为脚本(字段零值 = 不做那件事)。 type fakeBrowserScript struct { // Stdout / Stderr 是要写出去的若干行(模拟浏览器自己说的话)。 Stdout []string `json:"stdout"` Stderr []string `json:"stderr"` // ExitCode 是退出码。 ExitCode int `json:"exitCode"` // WritePort > 0 ⇒ 往 profile 目录写 DevToolsActivePort(模拟真浏览器就绪); // Dial 只读这个文件,不会去连端口,所以端口号不必真的有人在听。 WritePort int `json:"writePort"` // SleepMS > 0 ⇒ 之后继续活着(模拟浏览器常驻):由 Dial 的停止函数或超时路径 Kill。 SleepMS int `json:"sleepMs"` } // TestMain 让测试二进制可以**兼任**假浏览器。 func TestMain(m *testing.M) { if dir, ok := fakeBrowserProfileDir(os.Args[1:]); ok { os.Exit(runFakeBrowser(dir)) } 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 } // runFakeBrowser 按脚本演一次假浏览器,返回退出码。 func runFakeBrowser(profileDir 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 } for _, line := range script.Stdout { fmt.Fprintln(os.Stdout, line) } for _, line := range script.Stderr { fmt.Fprintln(os.Stderr, line) } if script.WritePort > 0 { content := strconv.Itoa(script.WritePort) + "\nfake-browser-guid\n" if err := os.WriteFile(filepath.Join(profileDir, devToolsPortFile), []byte(content), 0o600); err != nil { fmt.Fprintf(os.Stderr, "fake browser: %v\n", err) return fakeBrowserNoScriptExit } } if script.SleepMS > 0 { time.Sleep(time.Duration(script.SleepMS) * time.Millisecond) } return script.ExitCode } // 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)) }