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 已写盯梢用例)
This commit is contained in:
AskaEth committed 2026-10-06 21:48:50 +08:00
1 parent 4213b268fe
commit e0a6bca501
18 files changed
+3698 -72

No files matched your search

+415
View File
@@ -0,0 +1,415 @@
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)
}
}