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