Files
Cyrene_v2/plugins/tools/browser/tool_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

195 lines
7.1 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 browser
import (
"context"
"errors"
"strings"
"testing"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// fakeDriver 是 Tool 依赖的最小驱动面(*browsercap.Manager 的真实现由驱动包的测试覆盖;
// 这里只验证**工具层**的接线与能力边界)。
type fakeDriver struct {
available bool
openedURL string
openErr error
shot browsercap.Shot
fp browsercap.Fingerprint
}
func (f *fakeDriver) Available() bool { return f.available }
func (f *fakeDriver) Source() string { return "fake" }
func (f *fakeDriver) Open(_ context.Context, raw string) (browsercap.PageResult, error) {
f.openedURL = raw
if f.openErr != nil {
return browsercap.PageResult{}, f.openErr
}
return browsercap.PageResult{URL: raw, Title: "t", ReadyState: "complete", Source: "fake"}, nil
}
func (f *fakeDriver) Screenshot(context.Context) (browsercap.Shot, error) { return f.shot, nil }
func (f *fakeDriver) Fingerprint(context.Context) (browsercap.Fingerprint, error) {
return f.fp, nil
}
// TestTool_ImplementsSDKAndName 钉住工具名(注册表条目名与热重载/接线测试都依赖它)。
func TestTool_ImplementsSDKAndName(t *testing.T) {
t.Parallel()
var _ sdk.Tool = New(&fakeDriver{})
if ToolName != "browser" {
t.Fatalf("ToolName = %q, want browser", ToolName)
}
if got := New(&fakeDriver{}).Name(); got != ToolName {
t.Fatalf("Name() = %q", got)
}
}
// TestTool_DescriptionDeclaresTheMissingActions 钉住**能力边界写进描述**:
// 模型只读这段文字,所以必须明说 click/type 不在这里实现、要走 MCP
// (AGENTS §9.5 的「不重造成熟工具面」要落成模型看得见的事实)。
func TestTool_DescriptionDeclaresTheMissingActions(t *testing.T) {
t.Parallel()
desc := New(&fakeDriver{}).Description()
for _, want := range []string{"MCP", "click", "vision"} {
if !strings.Contains(desc, want) {
t.Errorf("描述里缺少 %q:%s", want, desc)
}
}
}
// TestTool_ParametersExposeExactlyThreeActions 钉住动作面就是三个(多一个都要在
// 这里显式改写,避免「悄悄长出一整套自研工具面」)。
func TestTool_ParametersExposeExactlyThreeActions(t *testing.T) {
t.Parallel()
params, err := New(&fakeDriver{}).Parameters()
if err != nil {
t.Fatalf("Parameters: %v", err)
}
props, _ := params["properties"].(map[string]any)
action, _ := props["action"].(map[string]any)
enum, _ := action["enum"].([]any)
if len(enum) != 3 {
t.Fatalf("动作数 = %d, want 3: %v", len(enum), enum)
}
got := map[string]bool{}
for _, e := range enum {
s, _ := e.(string)
got[s] = true
}
for _, want := range []string{actionOpen, actionScreenshot, actionInfo} {
if !got[want] {
t.Errorf("动作枚举缺 %q", want)
}
}
required, _ := params["required"].([]any)
if len(required) != 1 || required[0] != "action" {
t.Errorf("required = %v, want [action]", required)
}
}
// TestTool_UnavailableReportsHonestly 钉住「不可用不假装可用」:驱动不可用(或没注入)
// 时每个动作都如实报错,不返回半真半假的结果。
func TestTool_UnavailableReportsHonestly(t *testing.T) {
t.Parallel()
for _, tool := range []*Tool{New(nil), New(&fakeDriver{available: false})} {
if _, err := tool.Execute(context.Background(), map[string]any{"action": actionInfo}); err == nil {
t.Fatal("不可用时应当报错")
} else if !strings.Contains(err.Error(), "没有可用的浏览器来源") {
t.Errorf("错误没有说清原因: %v", err)
}
}
}
// TestTool_OpenRequiresURL 钉住 action=open 缺 url 时**不发**导航请求(而不是拿空串去导航)。
func TestTool_OpenRequiresURL(t *testing.T) {
t.Parallel()
d := &fakeDriver{available: true}
tool := New(d)
if _, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen}); err == nil {
t.Fatal("缺 url 应当报错")
}
if _, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen, "url": " "}); err == nil {
t.Fatal("空白 url 应当报错")
}
if d.openedURL != "" {
t.Fatalf("不该发起导航,实得 %q", d.openedURL)
}
}
// TestTool_UnknownActionNamesTheAlternatives 钉住未知动作的错误里带可用清单
// (模型据此自我纠正,而不是盲试)。
func TestTool_UnknownActionNamesTheAlternatives(t *testing.T) {
t.Parallel()
_, err := New(&fakeDriver{available: true}).Execute(context.Background(), map[string]any{"action": "click"})
if err == nil {
t.Fatal("未知动作应当报错")
}
for _, want := range []string{"open", "screenshot", "info"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里应列出可用动作 %q: %v", want, err)
}
}
}
// TestTool_ScreenshotReturnsClosedLoopEntry 钉住截图动作的返回值**就是闭环入口**:
// 路径 + 指纹 + 提示「交给 vision」,而不是把图塞进上下文。
func TestTool_ScreenshotReturnsClosedLoopEntry(t *testing.T) {
t.Parallel()
d := &fakeDriver{available: true, shot: browsercap.Shot{
Path: "/data/browser/shots/shot-1.png", Bytes: 4242, SHA256: "abc",
Format: "png", Width: 1264, Height: 649, DimensionsKnown: true,
}}
got, err := New(d).Execute(context.Background(), map[string]any{"action": actionScreenshot})
if err != nil {
t.Fatalf("screenshot: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("返回值类型 = %T", got)
}
if m["path"] != d.shot.Path || m["sha256"] != "abc" || m["bytes"] != 4242 {
t.Errorf("返回值丢了落盘事实: %#v", m)
}
hint, _ := m["hint"].(string)
if !strings.Contains(hint, "vision") {
t.Errorf("提示里应指向 vision 工具: %q", hint)
}
}
// TestTool_OpenPassesURLThroughAndInfoReturnsFingerprint 钉住两个透传动作不打折扣。
func TestTool_OpenPassesURLThroughAndInfoReturnsFingerprint(t *testing.T) {
t.Parallel()
d := &fakeDriver{available: true, fp: browsercap.Fingerprint{Headless: false, Webdriver: false, Timezone: "Asia/Shanghai"}}
tool := New(d)
res, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen, "url": "https://example.com/"})
if err != nil {
t.Fatalf("open: %v", err)
}
if d.openedURL != "https://example.com/" {
t.Errorf("驱动收到的 URL = %q", d.openedURL)
}
if pr, ok := res.(browsercap.PageResult); !ok || pr.Title != "t" {
t.Errorf("open 结果 = %#v", res)
}
fp, err := tool.Execute(context.Background(), map[string]any{"action": actionInfo})
if err != nil {
t.Fatalf("info: %v", err)
}
if got, ok := fp.(browsercap.Fingerprint); !ok || got.Timezone != "Asia/Shanghai" {
t.Errorf("info 结果 = %#v", fp)
}
}
// TestTool_OpenSurfacesDriverError 钉住驱动错误原样上抛(不吞、不改写成「成功」)。
func TestTool_OpenSurfacesDriverError(t *testing.T) {
t.Parallel()
sentinel := errors.New("browser: 导航失败: net::ERR_NAME_NOT_RESOLVED")
_, err := New(&fakeDriver{available: true, openErr: sentinel}).Execute(
context.Background(), map[string]any{"action": actionOpen, "url": "https://nope.example/"})
if !errors.Is(err, sentinel) {
t.Fatalf("错误应可 errors.Is 回到驱动的那一个,实得: %v", err)
}
}