package main // 本文件钉住切片 V5 的组合根接线:`browser` 工具**只在有可用来源时**进注册面, // 每一个「不注册」的原因都**分类**写进日志(照 web-search `off` 的诚实口径), // 而 profile 目录的凭据守卫(不得进 git 工作树)在注册之前生效。 // // 断言落在**注册表**(模型真正看到的那一面)与**日志字段**上,而不是源码字符串。 import ( "bytes" "encoding/json" "os" "path/filepath" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" ) // browserTestApp 造一个「有注册面 + 有日志出口」的 app:browser 的接线只依赖这三件。 func browserTestApp(t *testing.T, cfg *config.Config, out *bytes.Buffer) *app { t.Helper() return &app{ cfg: cfg, log: logger.New(logger.Options{Out: out, Level: "debug"}), caps: registry.New(), } } // writeBrowserConfig 把一份 browser.json 写进 $DATA_DIR/config/plugins/(原样字节)。 func writeBrowserConfig(t *testing.T, cfg *config.Config, raw string) string { t.Helper() path := browsercfg.DefaultConfigPath(cfg.DataDir) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("建配置目录: %v", err) } if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { t.Fatalf("写 %s: %v", path, err) } return path } // browserEntry 报告注册面里有没有 browser 工具。 func browserEntry(a *app) (registry.Capability, bool) { cap, err := a.caps.Get(browsertools.ToolName) return cap, err == nil } // TestWireBrowser_InvalidConfigDoesNotRegister 钉住「配置非法 ⇒ 不注册 + 分类原因」: // 静默拿默认值顶替会让面板里那份坏配置永远看不见。 func TestWireBrowser_InvalidConfigDoesNotRegister(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) writeBrowserConfig(t, cfg, "{ this is not json") var out bytes.Buffer a := browserTestApp(t, cfg, &out) a.wireBrowser() if _, ok := browserEntry(a); ok { t.Fatal("配置非法时 browser 不该进注册面") } if got := wsLogField(t, out.String(), "reason"); got != browserReasonInvalidConfig { t.Errorf("reason = %q, want %q", got, browserReasonInvalidConfig) } } // TestWireBrowser_MissingExecutableDoesNotRegister 钉住「面板把路径写错 ⇒ 不注册」, // 且原因来自 browsercfg 的分类码(不偷偷回退到自动探测)。 func TestWireBrowser_MissingExecutableDoesNotRegister(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) raw, _ := json.Marshal(map[string]any{"executable": filepath.Join(t.TempDir(), "no-such-browser.exe")}) writeBrowserConfig(t, cfg, string(raw)) var out bytes.Buffer a := browserTestApp(t, cfg, &out) a.wireBrowser() if _, ok := browserEntry(a); ok { t.Fatal("可执行文件不存在时 browser 不该进注册面") } if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonExecutableMissing) { t.Errorf("reason = %q, want %q", got, browsercfg.ReasonExecutableMissing) } } // TestWireBrowser_UnreachableEndpointDoesNotRegister 钉住 CDP 端点连不上 ⇒ 不注册, // 原因分类为 cdp_unreachable(配置显式给了端点却不可达,就是「我配的容器没生效」, // 必须让人看见)。 func TestWireBrowser_UnreachableEndpointDoesNotRegister(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) // 127.0.0.1 上一个确定没人听的端口:连不上会立刻返回(不是超时)。 raw, _ := json.Marshal(map[string]any{"cdpEndpoint": "http://127.0.0.1:1"}) writeBrowserConfig(t, cfg, string(raw)) var out bytes.Buffer a := browserTestApp(t, cfg, &out) a.wireBrowser() if _, ok := browserEntry(a); ok { t.Fatal("CDP 端点不可达时 browser 不该进注册面") } if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonCDPUnreachable) { t.Errorf("reason = %q, want %q", got, browsercfg.ReasonCDPUnreachable) } } // TestWireBrowser_UsableExecutableRegisters 钉住正向路径:配置给出的可执行文件存在 // ⇒ 工具进注册面,且 profile 目录被按 0700 建好(含凭据的目录不能是 0755)。 func TestWireBrowser_UsableExecutableRegisters(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) exe := filepath.Join(t.TempDir(), "fake-browser.exe") if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o700); err != nil { t.Fatalf("造假可执行文件: %v", err) } profileDir := filepath.Join(cfg.DataDir, "browser", "profile-x") raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir}) 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()) } if cap.Kind != registry.KindTool { t.Errorf("Kind = %q, want tool", cap.Kind) } // 默认权限口径:owner-only(两布尔皆 false)。 if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger { t.Errorf("Policy = %+v,默认应是 owner-only(design/28 §4 第 2 条)", cap.Policy) } if _, err := os.Stat(profileDir); err != nil { t.Errorf("profile 目录应先被建好: %v", err) } // 工具名/动作面:注册进去的那把工具就是薄层那一个。 if tool, ok := cap.Value.(interface{ Name() string }); !ok || tool.Name() != browsertools.ToolName { t.Errorf("注册的值不是 browser 工具: %#v", cap.Value) } } // TestWireBrowser_ProfileInGitTreeIsRejected 钉住凭据守卫:profile 落在 git 工作树里 // ⇒ **不注册**(被 `git add` 收走就是把登录态提交出去,design/28 §4)。 func TestWireBrowser_ProfileInGitTreeIsRejected(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) exe := filepath.Join(t.TempDir(), "fake-browser.exe") if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil { t.Fatalf("造假可执行文件: %v", err) } // 造一个「像仓库」的目录:里面有 `.git`,profile 落在它下面、且在 dataDir 之外。 repo := t.TempDir() if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil { t.Fatalf("造 .git: %v", err) } profileDir := filepath.Join(repo, "browser-profile") raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir}) writeBrowserConfig(t, cfg, string(raw)) var out bytes.Buffer a := browserTestApp(t, cfg, &out) a.wireBrowser() if _, ok := browserEntry(a); ok { t.Fatal("profile 位于 git 工作树内时 browser 不该进注册面") } if got := wsLogField(t, out.String(), "reason"); got != browserReasonProfileRejected { t.Errorf("reason = %q, want %q", got, browserReasonProfileRejected) } } // TestIOToolOptions_DoesNotWireBrowser 钉住**临时挂点已删**(切片 X3 把挂点归位到 // `wireRuntime`):browser 的接线原先借 `ioToolOptions()` 转交(V5 改不了 // wire_runtime.go 时的权宜),代价是「取一份 io 选项」这件只读的事会顺带探测并注册 // 浏览器工具 —— 所有只为要选项而调本函数的路径(web-search 的接线测试就是)都会连带 // 起一次浏览器探测。归位后的挂点在 wire_runtime.go,由 // TestWireRuntime_CallsWireBrowser 钉住;这里钉住 io 侧**不再有第二个挂点** —— // 两处并存不会报错(函数幂等),只会静默多一次探测,因此只有测试能拦住它回流。 func TestIOToolOptions_DoesNotWireBrowser(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) exe := filepath.Join(t.TempDir(), "fake-browser.exe") if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil { t.Fatalf("造假可执行文件: %v", err) } raw, _ := json.Marshal(map[string]any{"executable": exe}) writeBrowserConfig(t, cfg, string(raw)) var out bytes.Buffer a := browserTestApp(t, cfg, &out) a.ioToolOptions() if _, ok := browserEntry(a); ok { t.Fatalf("ioToolOptions 不该再挂 browser(挂点已归位 wire_runtime.go;日志:%s)", out.String()) } } // TestWireBrowser_Idempotent 钉住**幂等**:同一个 app 上重复装配只发现工具已在注册表里 // 并跳过,不重复注册、不重复探测。挂点归位后幂等仍是唯一的保险(两条路径万一偶遇)。 func TestWireBrowser_Idempotent(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) exe := filepath.Join(t.TempDir(), "fake-browser.exe") if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil { t.Fatalf("造假可执行文件: %v", err) } raw, _ := json.Marshal(map[string]any{"executable": exe}) writeBrowserConfig(t, cfg, string(raw)) var out bytes.Buffer a := browserTestApp(t, cfg, &out) a.wireBrowser() first, ok := browserEntry(a) if !ok { t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String()) } a.wireBrowser() second, ok := browserEntry(a) if !ok { t.Fatal("第二次调用后 browser 不该消失") } if first.Name != second.Name || first.Kind != second.Kind { t.Errorf("重复装配改写了条目:%+v -> %+v", first, second) } // 注册面里只该有一个 browser(registry 重名会被拒;这里确认没被静默换掉)。 count := 0 for _, c := range a.caps.List(registry.KindTool) { if c.Name == browsertools.ToolName { count++ } } if count != 1 { t.Errorf("注册面里 browser 条目数 = %d, want 1", count) } } // TestWireBrowser_NilRegistryIsSkipped 钉住夹具友好性:没有注册面时**不 panic** // (部分既有测试夹具只填了 app 的几个字段)。 func TestWireBrowser_NilRegistryIsSkipped(t *testing.T) { t.Parallel() a := &app{} a.wireBrowser() }