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) } }