- 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 已写盯梢用例)
307 lines
11 KiB
Go
307 lines
11 KiB
Go
package browser
|
||
|
||
// 本文件钉住 B4 面板的**声明面与数据面**(design/09 §5.2/§5.2.1、design/28 §8 B4):
|
||
// 声明能过宿主闭集校验、动作名与声明同源、行 key 与列 key 同源、只读区块里没有
|
||
// 任何会让 viewer 看到浏览内容的字段。
|
||
//
|
||
// 「动作真能分发 + 读写面分类正确」用真实面板中枢验证,落在组合根
|
||
// (`cmd/cyrene/wire_browser_panel_test.go`):那两件事的判定者不是本包。
|
||
|
||
import (
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// testPanelInput 造一份声明入参(配置取默认值 + 给定可用性)。
|
||
func testPanelInput(available bool) PanelInput {
|
||
return PanelInput{
|
||
Config: browsercfg.Defaults(`C:\data`),
|
||
Available: available,
|
||
Source: "本机 Chrome(有头)",
|
||
}
|
||
}
|
||
|
||
// TestAdminPanelsPassHostValidation 钉住声明能过宿主闭集校验(形状错 ⇒ 整个面板被拒,
|
||
// 那是最难排查的一种失败:面板「就是不出现」,而日志里才有原因)。
|
||
func TestAdminPanelsPassHostValidation(t *testing.T) {
|
||
t.Parallel()
|
||
if err := paneldecl.Validate(PanelPluginID, AdminPanels(testPanelInput(true))); err != nil {
|
||
t.Fatalf("宿主校验不通过(面板会被整体拒掉): %v", err)
|
||
}
|
||
if err := paneldecl.Validate(PanelPluginID, AdminPanels(testPanelInput(false))); err != nil {
|
||
t.Fatalf("不可用档的声明也必须过校验: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestPanelPluginIDMatchesConfigFileName 钉住「文件名 = 插件 id」(design/13 §2.1):
|
||
// 两个口径一旦分叉,面板写的配置与工具读的配置就不是同一份。
|
||
func TestPanelPluginIDMatchesConfigFileName(t *testing.T) {
|
||
t.Parallel()
|
||
path := browsercfg.DefaultConfigPath(`C:\data`)
|
||
base := filepath.Base(path)
|
||
if base != PanelPluginID+".json" {
|
||
t.Fatalf("配置文件名 %q 与插件 id %q 不同源", base, PanelPluginID)
|
||
}
|
||
if dir := filepath.Base(filepath.Dir(path)); dir != "plugins" {
|
||
t.Fatalf("配置落点目录 = %q, want plugins", dir)
|
||
}
|
||
}
|
||
|
||
// TestAdminPanelsActionsAreDeclaredOnce 钉住「声明里的动作名 ⊆ ActionNames()」且无重复:
|
||
// 否则「声明了却没实现」或「白名单漏一个」都会变成前端点了没反应的半渲染。
|
||
func TestAdminPanelsActionsAreDeclaredOnce(t *testing.T) {
|
||
t.Parallel()
|
||
declared := make(map[string]bool)
|
||
for _, n := range ActionNames() {
|
||
if declared[n] {
|
||
t.Fatalf("ActionNames() 里 %q 重复", n)
|
||
}
|
||
declared[n] = true
|
||
}
|
||
used := make([]string, 0, len(declared))
|
||
for _, p := range AdminPanels(testPanelInput(true)) {
|
||
for _, s := range p.Sections {
|
||
if s.Submit != nil {
|
||
used = append(used, s.Submit.Action)
|
||
}
|
||
if s.Source != nil {
|
||
used = append(used, s.Source.Action)
|
||
}
|
||
for _, a := range s.RowActions {
|
||
used = append(used, a.Action)
|
||
}
|
||
}
|
||
}
|
||
if len(used) != len(declared) {
|
||
t.Fatalf("声明用了 %d 个动作,ActionNames() 给了 %d 个:%v", len(used), len(declared), used)
|
||
}
|
||
for _, name := range used {
|
||
if !declared[name] {
|
||
t.Errorf("声明里的动作 %q 不在 ActionNames() 里", name)
|
||
}
|
||
}
|
||
for _, name := range ActionNames() {
|
||
if strings.ContainsAny(name, "/:") {
|
||
t.Errorf("动作名 %q 含 URL 成分(§5.4.4:只有动作名,没有 URL)", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestActionNamesAreTheContract 钉住六个动作名的**字面量**:它们是前端与动作端点之间的
|
||
// 契约(前端按声明里的名字调 `POST /admin/plugins/{id}/actions/{action}`),改名会让面板上
|
||
// 的按钮静默失效。因此这里写死期望值 —— 改名字必须同时改这里(一个显式的动作)。
|
||
func TestActionNamesAreTheContract(t *testing.T) {
|
||
t.Parallel()
|
||
want := []string{
|
||
"plugin.saveBrowserConfig",
|
||
"plugin.getBrowserState",
|
||
"plugin.getFingerprint",
|
||
"plugin.listShots",
|
||
"plugin.shootPage",
|
||
"plugin.checkFingerprint",
|
||
}
|
||
got := ActionNames()
|
||
if len(got) != len(want) {
|
||
t.Fatalf("动作数 = %d, want %d:%v", len(got), len(want), got)
|
||
}
|
||
for i, name := range want {
|
||
if got[i] != name {
|
||
t.Errorf("ActionNames()[%d] = %q, want %q", i, got[i], name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestAdminPanelsCarryNoSecretsAndNoURLs 钉住两条红线:声明里没有 `secret` 字段
|
||
// (本插件没有凭据面,多一个都说明有人把凭据塞进了面板声明),也没有任何 URL 文本
|
||
// (防线:声明里塞不进请求面)。
|
||
func TestAdminPanelsCarryNoSecretsAndNoURLs(t *testing.T) {
|
||
t.Parallel()
|
||
for _, p := range AdminPanels(testPanelInput(true)) {
|
||
for _, s := range p.Sections {
|
||
for _, f := range s.Fields {
|
||
if f.Type == sdk.FieldSecret {
|
||
t.Errorf("字段 %q 是 secret:browser 插件的配置里没有凭据面", f.Key)
|
||
}
|
||
if strings.Contains(f.Placeholder, "http") && f.Key != FieldCDPEndpoint {
|
||
t.Errorf("字段 %q 的提示里带 URL(%q)", f.Key, f.Placeholder)
|
||
}
|
||
}
|
||
if s.Source != nil && strings.Contains(s.Source.Action, "://") {
|
||
t.Errorf("source 里有 URL: %q", s.Source.Action)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestConfigSectionDefaultsMirrorCurrentConfig 钉住「打开即显示现状」:表单字段的默认值
|
||
// 就是当前生效配置(面板因此不必自己读文件 —— 读的是同一份事实)。
|
||
func TestConfigSectionDefaultsMirrorCurrentConfig(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := browsercfg.Config{
|
||
Headless: true,
|
||
Executable: `C:\chrome.exe`,
|
||
CDPEndpoint: "http://127.0.0.1:9222",
|
||
ProfileDir: `C:\data\browser\profile-x`,
|
||
ViewportWidth: 1600,
|
||
ViewportHeight: 900,
|
||
IdleTimeoutSeconds: 120,
|
||
Concurrency: 1,
|
||
}
|
||
cfg = cfg.Normalize(`C:\data`)
|
||
want := map[string]any{
|
||
FieldHeadless: cfg.Headless,
|
||
FieldExecutable: cfg.Executable,
|
||
FieldCDPEndpoint: cfg.CDPEndpoint,
|
||
FieldProfileDir: cfg.ProfileDir,
|
||
FieldViewportWidth: cfg.ViewportWidth,
|
||
FieldViewportHeight: cfg.ViewportHeight,
|
||
FieldIdleTimeout: cfg.IdleTimeoutSeconds,
|
||
FieldConcurrency: cfg.Concurrency,
|
||
}
|
||
got := map[string]any{}
|
||
for _, s := range configSection(cfg).Fields {
|
||
got[s.Key] = s.Default
|
||
}
|
||
for key, v := range want {
|
||
if got[key] != v {
|
||
t.Errorf("字段 %s 默认值 = %v, want %v", key, got[key], v)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestPanelUnavailableDescriptionIsHonest 钉住「没有来源时说明白」:面板描述必须点出
|
||
// 工具**此刻不在**模型能力面里(照 web-search `off` 的诚实口径),而不是含糊地说「可能不可用」。
|
||
func TestPanelUnavailableDescriptionIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
panels := AdminPanels(testPanelInput(false))
|
||
desc := panels[0].Description
|
||
for _, want := range []string{"没有", "不在"} {
|
||
if !strings.Contains(desc, want) {
|
||
t.Errorf("不可用档的描述里缺 %q:%s", want, desc)
|
||
}
|
||
}
|
||
okDesc := AdminPanels(testPanelInput(true))[0].Description
|
||
if strings.Contains(okDesc, "⚠️") {
|
||
t.Errorf("可用档不该出现「没有可用来源」的告警:%s", okDesc)
|
||
}
|
||
}
|
||
|
||
// TestPanelDeclaresThreeReadSourcesAndTwoRowActions 钉住页面构成的**形状**:
|
||
// 三个读来源(状态/指纹/截图)与两个行内动作(拍一张 / 重新自检),且都没有 `submit`
|
||
// (行内动作是写面,submit 是另一条写面,混在一起会让「哪个按钮写了什么」不再可查)。
|
||
func TestPanelDeclaresThreeReadSourcesAndTwoRowActions(t *testing.T) {
|
||
t.Parallel()
|
||
sections := AdminPanels(testPanelInput(true))[0].Sections
|
||
sources, rowActions, submits := 0, 0, 0
|
||
for _, s := range sections {
|
||
if s.Source != nil {
|
||
sources++
|
||
}
|
||
rowActions += len(s.RowActions)
|
||
if s.Submit != nil {
|
||
submits++
|
||
}
|
||
}
|
||
if sources != 3 || rowActions != 2 || submits != 1 {
|
||
t.Fatalf("页面形状 = sources:%d rowActions:%d submits:%d, want 3/2/1", sources, rowActions, submits)
|
||
}
|
||
}
|
||
|
||
// 本文件的三个夹具:与驱动层的事实面同形(不含内容,只统计)。
|
||
func stateFixture() browsercap.StateSnapshot {
|
||
return browsercap.StateSnapshot{
|
||
Source: "本机 Chrome(有头)", Available: true, Headless: false,
|
||
ViewportWidth: 1280, ViewportHeight: 800, ProfileDir: `C:\datarowser\profile`,
|
||
ShotsDir: `C:\datarowser\shots`, Concurrency: 1,
|
||
TimeoutSeconds: 30, IdleTimeoutSeconds: 300, ScreenshotMaxBytes: 8 << 20,
|
||
}
|
||
}
|
||
|
||
func fingerprintFixture() browsercap.Fingerprint {
|
||
return browsercap.Fingerprint{
|
||
Headless: false, Webdriver: false, UserAgent: "Mozilla/5.0", Platform: "Win32",
|
||
Language: "zh-CN", Languages: []string{"zh-CN", "en"}, Timezone: "Asia/Shanghai",
|
||
ScreenWidth: 2560, ScreenHeight: 1440, ColorDepth: 24, DevicePixelRatio: 1,
|
||
}
|
||
}
|
||
|
||
func shotRecordFixture() browsercap.ShotRecord {
|
||
return browsercap.ShotRecord{
|
||
At: time.Date(2026, 10, 6, 21, 30, 0, 0, time.UTC),
|
||
Bytes: 123456, Width: 1280, Height: 800, DimensionsKnown: true, Format: "png",
|
||
}
|
||
}
|
||
|
||
// TestRowsMatchDeclaredColumns 钉住「行 key 与列 key 同源」:声明一个 key、行里写另一个
|
||
// 不会有任何报错,只会让表头下一片空白 —— 只有这类断言拦得住。
|
||
func TestRowsMatchDeclaredColumns(t *testing.T) {
|
||
t.Parallel()
|
||
fp := fingerprintFixture()
|
||
sections := AdminPanels(testPanelInput(true))[0].Sections
|
||
rows := map[string][]map[string]any{
|
||
"state": StateRows(stateFixture()),
|
||
"fingerprint": FingerprintRows(&fp, shotRecordFixture().At),
|
||
"shots": ShotRows([]browsercap.ShotRecord{shotRecordFixture()}),
|
||
}
|
||
for _, s := range sections {
|
||
if s.Source == nil {
|
||
continue
|
||
}
|
||
got := rows[s.ID]
|
||
if len(got) == 0 {
|
||
t.Errorf("区块 %s 没有对应的行夹具(用例没跟上声明)", s.ID)
|
||
continue
|
||
}
|
||
for _, row := range got {
|
||
for key := range row {
|
||
found := false
|
||
for _, c := range s.Columns {
|
||
if c.Key == key {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("区块 %s 的行里有未声明的列 key %q", s.ID, key)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestUncheckedFingerprintIsHonest 钉住「未自检就写未自检」:面板不拿一组假数字顶替
|
||
// (那是「看起来好了」的最经典形态)。
|
||
func TestUncheckedFingerprintIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
rows := FingerprintRows(nil, time.Time{})
|
||
if len(rows) != 1 {
|
||
t.Fatalf("未自检时应给一行事实:%+v", rows)
|
||
}
|
||
for key, v := range rows[0] {
|
||
if v != notCheckedText {
|
||
t.Errorf("未自检的 %s = %v, want %q", key, v, notCheckedText)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestReadOnlyRowsCarryNoPathsOrURLs 钉住隐私口径:只读行里**没有**截图路径(只有统计)。
|
||
// 面板的只读区块连 viewer 都能看,把文件路径与截图内容投到那里是越界。
|
||
func TestReadOnlyRowsCarryNoPathsOrURLs(t *testing.T) {
|
||
t.Parallel()
|
||
for _, row := range ShotRows([]browsercap.ShotRecord{shotRecordFixture()}) {
|
||
for key, v := range row {
|
||
text, _ := v.(string)
|
||
if strings.ContainsAny(text, `/\`) {
|
||
t.Errorf("截图行 %s 里出现了路径样文本:%v", key, v)
|
||
}
|
||
}
|
||
}
|
||
}
|