Files
Cyrene_v2/cmd/cyrene/wire_browser_panel_test.go
T
AskaEth e0a6bca501 feat(browser): B3 截图→上下文闭环 + B4 面板声明页(Y4)
- B3:截图落盘后**自动**并入本轮视图(能收图 ⇒ 真 image 部件挂 user 消息;收不了 ⇒ vision/OCR 文字;
  大图先经 image-crop 裁切;取走即消费、每会话 ≤4 张、TTL 10min、跨会话隔离)
- B4:browser 面板**声明**(配置表单 + 运行状态/指纹自检/最近截图三张只读表 + 拍一张/重新自检)
  + 六个动作走 PLG-2 的通用动作端点(**不开新路由**)+ 保存事务(不可用则回滚 409)+ 热重载
- 读面**不启动浏览器**(计数型假 dialer 证明 0 次拨号);行数据不含路径/URL/内容
- 3 个变异(短路主路/行与列 key 脱钩/保存不回滚)
⚠️ **两行接线未落**(不在我文件面):wire_runtime.go 的 TransformContext + wire_frontend.go 的
   wireBrowserPanel —— 落地前 B3/B4 只是「组件就绪」,跑着的后端里不存在(Y4 已写盯梢用例)
2026-10-06 21:48:50 +08:00

416 lines
16 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 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)
}
}