- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
148 lines
5.2 KiB
Go
148 lines
5.2 KiB
Go
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"])
|
||
}
|