package main // 本文件是 `browser` 接线的**真机端到端**(默认不跑):从浏览器配置 → 组合根接线 → // 注册面 → 模型会调用的那把工具 → 真 Chrome → 截图落盘,全程不假任何一层。 // // 用法(Windows / Git Bash): // // CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestBrowserTool_RealHostEndToEnd -v -timeout 5m // // 为什么默认 Skip:它会**真的起一个浏览器**、并且要打一次外网(本机没有 Chrome 时 // 自动 Skip)。这类验证属于「人工按需复跑」,不该混进普通 `go test`(AGENTS §10 // 的压测纪律:不在验收里无界占机)。 // // 为什么用**无头**:这是自动化验证,不该在主人的桌面上弹一个窗口;有头路径的 // 真实验证走 `internal/capability/browser/compare_test.go` 的对照脚本(它默认无头/有头各跑一遍)。 import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // browserE2EEnabled 报告是否显式开启了真机 e2e。 func browserE2EEnabled() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv("CYRENE_BROWSER_E2E"))) { case "1", "true", "yes", "on": return true default: return false } } // TestBrowserTool_RealHostEndToEnd 走完整条链并断言**页面自报**的事实。 func TestBrowserTool_RealHostEndToEnd(t *testing.T) { if !browserE2EEnabled() { t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)") } exe, ok := browsercfg.NewProber().FindLocalBrowser() if !ok { t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行") } cfg := wsLoadConfig(t) raw, _ := json.Marshal(map[string]any{ "executable": exe, "headless": true, "timeoutSeconds": 60, }) writeBrowserConfig(t, cfg, string(raw)) var out bytes.Buffer a := browserTestApp(t, cfg, &out) a.wireBrowser() cap, ok := browserEntry(a) if !ok { t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String()) } tool, ok := cap.Value.(sdk.Tool) if !ok { t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value) } // 收尾必须真的把浏览器关掉:用例结束会删临时目录(含 profile),Chrome 还在跑就会被 // 占用而删不掉。这条 defer 同时是**生产缺口**的现场证明(Tool.Close 目前无人调用)。 if closer, ok := tool.(interface{ Close() error }); ok { defer func() { if cerr := closer.Close(); cerr != nil { t.Errorf("Close: %v", cerr) } }() } else { t.Error("工具没实现 Close:组合根将没有关闭浏览器的挂点") } ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) defer cancel() // ① 指纹自检:证明「这次真的是没在自曝」——navigator.webdriver 必须是 false。 info, err := tool.Execute(ctx, map[string]any{"action": "info"}) if err != nil { t.Fatalf("action=info: %v", err) } fp, ok := info.(browsercap.Fingerprint) if !ok { t.Fatalf("info 结果类型 = %T", info) } if fp.Webdriver { t.Errorf("navigator.webdriver = true:自动化痕迹没关掉(%+v)", fp) } t.Logf("指纹自检:headless=%v webdriver=%v ua=%s tz=%s lang=%s screen=%dx%d outer=%dx%d", fp.Headless, fp.Webdriver, fp.UserAgent, fp.Timezone, fp.Language, fp.ScreenWidth, fp.ScreenHeight, fp.OuterWidth, fp.OuterHeight) if strings.Contains(strings.ToLower(fp.UserAgent), "headlesschrome") { t.Logf("提示:无头档的 UA 带 HeadlessChrome —— 这是无头与有头唯一必然不同的一位") } // ② 导航:读回页面**自报**的标题(不是我们以为的)。 opened, err := tool.Execute(ctx, map[string]any{"action": "open", "url": "https://example.com/"}) if err != nil { t.Fatalf("action=open: %v", err) } page, ok := opened.(browsercap.PageResult) if !ok { t.Fatalf("open 结果类型 = %T", opened) } if !strings.Contains(page.Title, "Example") { t.Errorf("标题 = %q,期望页面自报的 Example Domain", page.Title) } if page.ReadyState != "complete" { t.Errorf("readyState = %q", page.ReadyState) } t.Logf("导航:title=%q url=%q readyState=%q source=%q", page.Title, page.URL, page.ReadyState, page.Source) // ③ 截图落盘:路径存在、非空、尺寸来自字节本身。 shotRaw, err := tool.Execute(ctx, map[string]any{"action": "screenshot"}) if err != nil { t.Fatalf("action=screenshot: %v", err) } shot := shotRaw.(map[string]any) path, _ := shot["path"].(string) if path == "" { t.Fatalf("截图没有路径: %#v", shot) } fi, err := os.Stat(path) if err != nil { t.Fatalf("截图没落盘: %v", err) } if fi.Size() == 0 { t.Fatal("截图是 0 字节") } if !strings.HasPrefix(path, filepath.Join(cfg.DataDir, "browser", "shots")) { t.Errorf("截图没落在 $DATA_DIR/browser/shots 下: %s", path) } width, _ := shot["width"].(int) height, _ := shot["height"].(int) if width <= 0 || height <= 0 { t.Errorf("尺寸没解出来: %#v", shot) } t.Logf("截图:path=%s bytes=%v %dx%d sha256=%v", path, shot["bytes"], width, height, shot["sha256"]) }