- 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 已写盯梢用例)
416 lines
16 KiB
Go
416 lines
16 KiB
Go
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)
|
||
}
|
||
}
|