Files
Cyrene_v2/plugins/tools/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

307 lines
11 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 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)
}
}
}
}