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) } } } }