Files
Cyrene_v2/cmd/cyrene/wire_browser_e2e_test.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): B1 接线 + B2 薄层工具面(V5)
- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

148 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"])
}