package main // 本文件是 design/28 §8 **B4(M3 面板入口)的接线验证**(切片 Y4): // 声明真的进了面板目录、六个动作真的能分发、读写面分类正确、保存真的热重载。 // // 为什么这些断言必须走**真实面板中枢**(`pluginmgr.NewPanels`)而不是只测函数: // // - 「动作可不可调」由声明推出(`pluginmgr.actionIndex`),viewer 的写面在**快照裁剪** // 里被摘掉(`paneldecl.filterPanel`)—— 两件事都不是本包能自证的; // - 保存的语义是「盘上改了 + 运行里的工具也换了」,只有从**能力注册表**读回工具才能验。 // // 真浏览器不在这里:`buildBrowserTool` 只按配置探测**可执行文件是否存在**,因此本文件用 // 一个假的可执行文件就能走完装配(真机 e2e 见 `wire_browser_e2e_test.go`,默认 Skip)。 import ( "bytes" "context" "encoding/json" "errors" "log/slog" "net/http" "os" "path/filepath" "runtime" "strconv" "testing" auth "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" ) // browserPanelFixture 是 B4 的夹具:真 hub + 已注册的 browser 工具(假可执行文件)。 type browserPanelFixture struct { app *app hub *pluginmgr.Panels log *slog.Logger out *bytes.Buffer path string } // newBrowserPanelFixture 造夹具并完成接线。 func newBrowserPanelFixture(t *testing.T) *browserPanelFixture { t.Helper() 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) } writeBrowserConfig(t, cfg, `{"executable":`+strconv.Quote(exe)+`}`) var out bytes.Buffer a := &app{ cfg: cfg, log: logger.New(logger.Options{Out: &out, Level: "debug"}), caps: registry.New(), } a.wireBrowser() if _, ok := browserEntry(a); !ok { t.Fatalf("夹具前提不成立:明明可用却没注册(日志:%s)", out.String()) } hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel")) if err != nil { t.Fatalf("NewPanels: %v", err) } log := logger.WithSource(a.log, "test/panel") wireBrowserPanel(hub, a, log) fx := &browserPanelFixture{ app: a, hub: hub, log: log, out: &out, path: browsercfg.DefaultConfigPath(cfg.DataDir), } t.Cleanup(func() { if entry, err := a.caps.Get(browsertools.ToolName); err == nil { if closer, ok := entry.Value.(interface{ Close() error }); ok { _ = closer.Close() } } }) return fx } // dispatch 调一次动作(等价于宿主动作端点的分发路径:声明 → 启用 → 处理器)。 func (fx *browserPanelFixture) dispatch(t *testing.T, action string, payload any) (any, error) { t.Helper() return fx.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: browsertools.PanelPluginID, Action: action, Payload: payload, }) } // TestWireBrowserPanel_DeclaresPanelAndActionKinds 钉住声明进了目录,且六个动作的读写面 // 分类正确(读面 viewer 可调、写面只 owner/admin —— 这是 §5.4.1 的门)。 func TestWireBrowserPanel_DeclaresPanelAndActionKinds(t *testing.T) { t.Parallel() fx := newBrowserPanelFixture(t) groups, err := fx.hub.Groups(auth.RoleOwner) if err != nil { t.Fatalf("Groups(owner): %v", err) } if len(groups) != 1 || len(groups[0].Panels) != 1 { t.Fatalf("面板快照形状 = %+v", groups) } item := groups[0].Panels[0] if item.PluginID != browsertools.PanelPluginID || item.Panel.ID != browsertools.PanelIDSettings { t.Fatalf("面板标识 = %s/%s", item.PluginID, item.Panel.ID) } if len(item.Panel.Sections) != 4 { t.Fatalf("区块数 = %d, want 4(配置 / 状态 / 指纹 / 截图)", len(item.Panel.Sections)) } wantKinds := map[string]pluginmgr.ActionKind{ browsertools.ActionState: pluginmgr.ActionRead, browsertools.ActionShots: pluginmgr.ActionRead, browsertools.ActionFingerprint: pluginmgr.ActionRead, browsertools.ActionSaveConfig: pluginmgr.ActionWrite, browsertools.ActionShoot: pluginmgr.ActionWrite, browsertools.ActionCheck: pluginmgr.ActionWrite, } for name, want := range wantKinds { got, ok := fx.hub.Action(browsertools.PanelPluginID, name) if !ok { t.Errorf("动作 %s 没进声明索引(面板会被宿主拒掉或点了没反应)", name) continue } if got != want { t.Errorf("动作 %s 的读写面 = %q, want %q", name, got, want) } } } // TestWireBrowserPanel_ViewerSeesReadOnlyProjection 钉住角色裁剪:viewer 看到的分组里 // **没有**表单(写面)与行内动作(写动作),只留只读表格 —— 这是「只读监控投影」的定义。 func TestWireBrowserPanel_ViewerSeesReadOnlyProjection(t *testing.T) { t.Parallel() fx := newBrowserPanelFixture(t) groups, err := fx.hub.Groups(auth.RoleViewer) if err != nil { t.Fatalf("Groups(viewer): %v", err) } if len(groups) != 1 || len(groups[0].Panels) != 1 { t.Fatalf("viewer 的面板快照形状 = %+v", groups) } for _, s := range groups[0].Panels[0].Panel.Sections { if s.Type == "form" { t.Errorf("viewer 拿到了写表单(%s)", s.ID) } if len(s.RowActions) > 0 { t.Errorf("viewer 拿到了行内写动作(%s:%+v)", s.ID, s.RowActions) } } // 对照:owner 看得到表单与行内动作(否则上面的断言在「什么都没渲染」时也会过)。 ownerGroups, err := fx.hub.Groups(auth.RoleOwner) if err != nil { t.Fatalf("Groups(owner): %v", err) } forms, rowActions := 0, 0 for _, s := range ownerGroups[0].Panels[0].Panel.Sections { if s.Type == "form" { forms++ } rowActions += len(s.RowActions) } if forms != 1 || rowActions != 2 { t.Fatalf("owner 看到的表单/行内动作 = %d/%d, want 1/2", forms, rowActions) } } // TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser 钉住三件事: // 读动作真的能分发、回的是 `{rows:[…]}`、且**没有**启动浏览器会话(面板取数无副作用)。 func TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser(t *testing.T) { t.Parallel() fx := newBrowserPanelFixture(t) for _, action := range []string{ browsertools.ActionState, browsertools.ActionShots, browsertools.ActionFingerprint, } { res, err := fx.dispatch(t, action, map[string]any{}) if err != nil { t.Fatalf("%s: %v", action, err) } m, ok := res.(map[string]any) if !ok { t.Fatalf("%s 结果类型 = %T", action, res) } rows, ok := m["rows"].([]map[string]any) if !ok || rows == nil { t.Fatalf("%s 的 rows = %#v(必须是数组,哪怕是空的)", action, m["rows"]) } // 状态与指纹**恒有一行**(事实面),最近截图在没有截图时如实为空表。 if action != browsertools.ActionShots && len(rows) == 0 { t.Fatalf("%s 应当至少一行事实:%#v", action, m["rows"]) } } tool := browserToolValue(t, fx) if snap := tool.State(); snap.SessionLive { t.Error("读动作启动了浏览器会话:面板取数必须无进程外副作用") } } // TestWireBrowserPanel_SaveConfigHotReloads 钉住「保存即生效」:落盘 + 换掉运行中的工具 // (面板的意义就是不改 `.env`、不重启)。 func TestWireBrowserPanel_SaveConfigHotReloads(t *testing.T) { t.Parallel() fx := newBrowserPanelFixture(t) res, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{ "values": map[string]any{ browsertools.FieldViewportWidth: float64(1600), browsertools.FieldViewportHeight: float64(900), browsertools.FieldIdleTimeout: float64(120), browsertools.FieldHeadless: true, }, }) if err != nil { t.Fatalf("saveBrowserConfig: %v", err) } m, _ := res.(map[string]any) if m["applied"] != true || m["registered"] != true { t.Fatalf("保存结论 = %#v(要求盘上与运行中都生效)", m) } snap := browserToolValue(t, fx).State() if snap.ViewportWidth != 1600 || snap.ViewportHeight != 900 || snap.IdleTimeoutSeconds != 120 || !snap.Headless { t.Fatalf("热重载后的生效配置 = %+v,与保存内容不一致", snap) } raw, err := os.ReadFile(fx.path) if err != nil { t.Fatalf("读回配置: %v", err) } var onDisk browsercfg.Config if err := json.Unmarshal(raw, &onDisk); err != nil { t.Fatalf("配置不是合法 JSON: %v", err) } if onDisk.ViewportWidth != 1600 || !onDisk.Headless { t.Fatalf("盘上的配置 = %+v", onDisk) } // 面板默认值跟着刷新(前端提交成功后会重取快照,表单因此显示「刚落盘的那份」)。 groups, _ := fx.hub.Groups(auth.RoleOwner) for _, s := range groups[0].Panels[0].Panel.Sections { if s.Type != "form" { continue } for _, f := range s.Fields { if f.Key == browsertools.FieldViewportWidth && f.Default != 1600 { t.Errorf("面板默认值没刷新:viewportWidth=%v", f.Default) } } } } // TestWireBrowserPanel_SaveConfigRejectsUnknownField 钉住「未知字段不许静默忽略」: // 静默忽略会让「我改了但它没生效」变成查不出的谜,而且不该在盘上留下半份改动。 func TestWireBrowserPanel_SaveConfigRejectsUnknownField(t *testing.T) { t.Parallel() fx := newBrowserPanelFixture(t) before, _ := os.ReadFile(fx.path) _, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{ "values": map[string]any{"nope": "x"}, }) ae := requireActionError(t, err) if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest { t.Fatalf("失败形状 = %+v, want invalid_payload/400", ae) } after, _ := os.ReadFile(fx.path) if !bytes.Equal(before, after) { t.Error("被拒的保存改动了盘上的配置文件") } } // TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable 钉住事务:保存前有可用工具而新配置 // 让它不可用(路径写错)⇒ **回滚**且如实报冲突,绝不让「盘上的」与「跑着的」静默拆开。 func TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable(t *testing.T) { t.Parallel() fx := newBrowserPanelFixture(t) before, _ := os.ReadFile(fx.path) _, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{ "values": map[string]any{ browsertools.FieldExecutable: filepath.Join(t.TempDir(), "no-such-browser.exe"), }, }) ae := requireActionError(t, err) if ae.Code != browserCodeConfigRejected || ae.Status != http.StatusConflict { t.Fatalf("失败形状 = %+v, want %s/409", ae, browserCodeConfigRejected) } after, _ := os.ReadFile(fx.path) if !bytes.Equal(before, after) { t.Error("没能生效的保存没有回滚:盘上留下了会让工具消失的配置") } if _, ok := browserEntry(fx.app); !ok { t.Error("回滚之后原来的工具也不见了(应当原样保留)") } } // TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget 钉住两个写动作的诚实下限: // 没有打开的页面时「拍一张」如实报 browser_session_not_open,绝不偷偷替模型开一页。 func TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget(t *testing.T) { t.Parallel() fx := newBrowserPanelFixture(t) _, err := fx.dispatch(t, browsertools.ActionShoot, map[string]any{ "row": map[string]any{"source": "本机 Chrome"}, }) ae := requireActionError(t, err) if ae.Code != browserCodeNoSession || ae.Status != http.StatusConflict { t.Fatalf("拍一张的失败形状 = %+v, want %s/409", ae, browserCodeNoSession) } } // TestWireBrowserPanel_WithoutToolIsHonest 钉住「没有浏览器来源」时的两个写动作:如实报 // browser_unavailable(而不是 500 / 假成功),且声明仍然在(面板可以配,配好保存即生效)。 func TestWireBrowserPanel_WithoutToolIsHonest(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) var out bytes.Buffer a := &app{cfg: cfg, log: logger.New(logger.Options{Out: &out, Level: "debug"}), caps: registry.New()} hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel")) if err != nil { t.Fatalf("NewPanels: %v", err) } wireBrowserPanel(hub, a, logger.WithSource(a.log, "test/panel")) groups, gerr := hub.Groups(auth.RoleOwner) if gerr != nil || len(groups) != 1 || len(groups[0].Panels) != 1 { t.Fatalf("没有工具时面板也该声明(可以在面板里配):%+v, %v", groups, gerr) } for _, action := range []string{browsertools.ActionShoot, browsertools.ActionCheck} { _, derr := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: browsertools.PanelPluginID, Action: action, }) ae := requireActionError(t, derr) if ae.Code != browserCodeUnavailable { t.Errorf("%s 的失败形状 = %+v, want %s", action, ae, browserCodeUnavailable) } } } /* --------------------------------------------------------------------------- * 助手 * ------------------------------------------------------------------------- */ // browserToolValue 从能力面取回 browser 工具。 func browserToolValue(t *testing.T, fx *browserPanelFixture) *browsertools.Tool { t.Helper() entry, err := fx.app.caps.Get(browsertools.ToolName) if err != nil { t.Fatalf("能力面里没有 browser 工具: %v", err) } tool, ok := entry.Value.(*browsertools.Tool) if !ok { t.Fatalf("能力面里的值不是 browser 工具: %T", entry.Value) } return tool } // requireActionError 断言错误是一条**结构化动作失败**(错误码 + 文案 + 状态)。 func requireActionError(t *testing.T, err error) *pluginmgr.ActionError { t.Helper() if err == nil { t.Fatal("期望一个结构化失败,得到 nil") } ae, ok := pluginmgr.AsActionError(err) if !ok { t.Fatalf("err = %v(%T),不是 structured ActionError", err, err) } if ae.Message == "" { t.Error("结构化失败没有文案(§5.4.6 要求错误码与文案分离且都在)") } return ae } // TestWireBrowserPanel_ConfigFileIsPrivate 钉住落盘权限:browser.json 可能带显式 CDP 端点 // 与 profile 位置,按 0600 落盘(`browsercfg.Save` 的口径,这里从**面板保存**这条路上验)。 func TestWireBrowserPanel_ConfigFileIsPrivate(t *testing.T) { t.Parallel() fx := newBrowserPanelFixture(t) if _, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{ "values": map[string]any{browsertools.FieldIdleTimeout: float64(90)}, }); err != nil { t.Fatalf("saveBrowserConfig: %v", err) } if runtime.GOOS == "windows" { // Windows 不体现 POSIX 权限位(仓库既有的 store_test.go 同口径跳过)。 t.Skip("Windows 不体现 POSIX 权限位;0600 的判定在 store_test.go 覆盖") } info, err := os.Stat(fx.path) if err != nil { t.Fatalf("stat 配置: %v", err) } if perm := info.Mode().Perm(); perm != 0o600 { t.Errorf("配置权限 = %o, want 600", perm) } } // TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile 钉住「坏配置不静默顶替」: // 文件非法时 `currentConfig` 返回错误(面板据此如实告知),不悄悄回落到默认值。 func TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile(t *testing.T) { t.Parallel() fx := newBrowserPanelFixture(t) if err := os.WriteFile(fx.path, []byte("{ not json"), 0o600); err != nil { t.Fatalf("写坏配置: %v", err) } if _, err := fx.dispatch(t, browsertools.ActionState, map[string]any{}); err != nil { t.Fatalf("读动作不该因为配置坏而失败(它用默认值兜底显示现状): %v", err) } _, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{ "values": map[string]any{browsertools.FieldIdleTimeout: float64(90)}, }) ae := requireActionError(t, err) if ae.Code != browserCodeConfigUnreadable { t.Fatalf("坏配置下保存的失败形状 = %+v, want %s", ae, browserCodeConfigUnreadable) } raw, _ := os.ReadFile(fx.path) if string(raw) != "{ not json" { t.Error("坏配置在保存失败后被改写了(不该动它)") } if !errors.Is(err, browsercfg.ErrInvalidConfig) { t.Logf("note: 错误链未含 ErrInvalidConfig(%v)", err) } }