Files
Cyrene_v2/internal/capability/browser/state_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

146 lines
5.2 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/28 §8 B4;形状见 state.go):
// 取数**不启动浏览器**、最近截图统计有界且最新在前、指纹缓存是深拷。
//
// 「面板取数无副作用」这条是硬口径:面板的只读区块连 viewer 都能看(design/09 §5.4.1),
// 让「打开页面看一眼」顺手起一个 Chrome 是明确的越界。因此这里用**计数型假 dialer**
// 证明 `State()` 一次都没碰过启动路径(比「看起来没起」强)。
import (
"context"
"sync/atomic"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// countingDialer 记录 Dial 调用次数:任何一次 `State()` 都不该让它 +1。
type countingDialer struct {
dials atomic.Int32
}
func (d *countingDialer) Dial(context.Context) (string, func(), error) {
d.dials.Add(1)
return "", func() {}, nil
}
func (d *countingDialer) Describe() string { return "counting-dialer" }
func (d *countingDialer) Local() bool { return false }
// newStateFixture 造一个带可用 dialer 的管理器(配置走默认值)。
func newStateFixture(t *testing.T) (*Manager, *countingDialer) {
t.Helper()
m := NewManager(Options{
Availability: browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome, Detail: "fixture", Headless: true,
},
Config: browsercfg.Defaults(`C:\data`),
DataDir: `C:\data`,
})
t.Cleanup(func() { _ = m.Close() })
d := &countingDialer{}
m.d = d
return m, d
}
// TestManagerState_ReadsFactsWithoutLaunching 钉住「取数无副作用」:State() 只读字段,
// 一次都不碰启动路径。
func TestManagerState_ReadsFactsWithoutLaunching(t *testing.T) {
t.Parallel()
m, d := newStateFixture(t)
for i := 0; i < 3; i++ {
snap := m.State()
if !snap.Available {
t.Fatalf("可用来源下 Available 应为 true:%+v", snap)
}
if snap.SessionLive {
t.Fatalf("State() 建出了会话:%+v", snap)
}
if snap.Headless != browsercfg.DefaultHeadless {
t.Errorf("Headless = %v, want %v", snap.Headless, browsercfg.DefaultHeadless)
}
if snap.ViewportWidth != browsercfg.DefaultViewportWidth || snap.ViewportHeight != browsercfg.DefaultViewportHeight {
t.Errorf("视口 = %dx%d", snap.ViewportWidth, snap.ViewportHeight)
}
if snap.Concurrency != browsercfg.DefaultConcurrency {
t.Errorf("并发上限 = %d, want %d(profile 锁)", snap.Concurrency, browsercfg.DefaultConcurrency)
}
if snap.Shots != nil {
t.Errorf("没有截图时 Shots 应为空:%+v", snap.Shots)
}
}
if got := d.dials.Load(); got != 0 {
t.Fatalf("State() 触发了 %d 次拨号(面板取数必须无进程外副作用)", got)
}
}
// TestManagerState_ShotRingIsBoundedAndNewestFirst 钉住最近截图统计的形状:
// 有界(不随运行时间无界增长)且最新在前(面板第一行是「刚才那一下」)。
func TestManagerState_ShotRingIsBoundedAndNewestFirst(t *testing.T) {
t.Parallel()
m, _ := newStateFixture(t)
base := time.Date(2026, 10, 6, 21, 0, 0, 0, time.UTC)
for i := 0; i < maxShotRecords+5; i++ {
m.mu.Lock()
m.recordShotLocked(Shot{Bytes: i + 1, Width: 8, Height: 8, DimensionsKnown: true, Format: "png"}, base.Add(time.Duration(i)*time.Second))
m.mu.Unlock()
}
shots := m.State().Shots
if len(shots) != maxShotRecords {
t.Fatalf("保留条数 = %d, want %d", len(shots), maxShotRecords)
}
if shots[0].Bytes != maxShotRecords+5 {
t.Fatalf("最新的一条不在最前:首条字节 = %d", shots[0].Bytes)
}
for i := 1; i < len(shots); i++ {
if !shots[i-1].At.After(shots[i].At) {
t.Fatalf("行序不是最新在前:%d 与 %d", i-1, i)
}
}
}
// TestManagerState_FingerprintIsCachedByValue 钉住指纹缓存是**深拷**:调用方改了返回的
// 读数不该改到管理器里的那一份(否则面板显示会被别人的改动悄悄污染)。
func TestManagerState_FingerprintIsCachedByValue(t *testing.T) {
t.Parallel()
m, _ := newStateFixture(t)
m.recordFingerprint(Fingerprint{Webdriver: false, Languages: []string{"zh-CN", "en"}, Timezone: "Asia/Shanghai"})
first := m.State()
if first.Fingerprint == nil || first.FingerprintAt.IsZero() {
t.Fatalf("指纹没被记下来:%+v", first)
}
first.Fingerprint.Languages[0] = "mutated"
if got := m.State().Fingerprint.Languages[0]; got != "zh-CN" {
t.Fatalf("指纹缓存被外部改动污染了:%q", got)
}
}
// TestManagerState_ClosedIsReported 钉住「已关闭」如实上报(面板据此显示「已关闭」而不是
// 假装还可用 —— 关闭之后任何动作都会报 ErrNoSession)。
func TestManagerState_ClosedIsReported(t *testing.T) {
t.Parallel()
m, _ := newStateFixture(t)
if err := m.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
snap := m.State()
if !snap.Closed {
t.Fatalf("关闭后 Closed = false:%+v", snap)
}
if snap.SessionLive {
t.Error("关闭后不该报会话活着")
}
}
// TestManagerState_NilManagerIsSafe 钉住 nil 管理器的兜底(面板在「工具没注册」那条路上
// 仍要取事实)。
func TestManagerState_NilManagerIsSafe(t *testing.T) {
t.Parallel()
var m *Manager
if snap := m.State(); snap.Available || snap.Closed {
t.Fatalf("nil 管理器的快照 = %+v", snap)
}
}