- 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 已写盯梢用例)
172 lines
7.2 KiB
Go
172 lines
7.2 KiB
Go
package browser
|
||
|
||
// 本文件钉住 B3 闭环的**浏览器侧**(design/28 §7 判据 2、切片 Y4):截图落盘后被放进
|
||
// 「本回合待送出」的附件箱,且**按会话隔离、取走即消费、有界**;没有会话 id 时如实报
|
||
// no-session 而不是丢进一个谁都能拿的公共格子。
|
||
//
|
||
// 视觉侧(图怎么变成 image 部件 / vision 工具文字)由
|
||
// `internal/runtime/multimodal/vision/shot_test.go` 覆盖,两处不重复。
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||
)
|
||
|
||
// fakeShot 造一张与落盘结论同形的截图(本文件不碰真浏览器)。
|
||
func fakeShot(path string) browsercap.Shot {
|
||
return browsercap.Shot{Path: path, Bytes: 1024, SHA256: "abc", Format: "png",
|
||
Width: 1280, Height: 800, DimensionsKnown: true}
|
||
}
|
||
|
||
// TestShotOutbox_PerSessionAndDrainOnce 钉住「按会话隔离 + 取走即消费」:
|
||
// A 会话的截图绝不出现在 B 会话的取用结果里,且同一张只能被送出一次。
|
||
func TestShotOutbox_PerSessionAndDrainOnce(t *testing.T) {
|
||
t.Parallel()
|
||
box := NewShotOutbox()
|
||
if got := box.Put("sess-a", fakeShot("/a.png")); got != TurnAttachmentQueued {
|
||
t.Fatalf("Put = %q, want %q", got, TurnAttachmentQueued)
|
||
}
|
||
if n := box.Pending("sess-b"); n != 0 {
|
||
t.Fatalf("B 会话不该看到 A 的截图,Pending=%d", n)
|
||
}
|
||
if got := box.Drain("sess-b"); got != nil {
|
||
t.Fatalf("B 会话 Drain = %v, want nil", got)
|
||
}
|
||
drained := box.Drain("sess-a")
|
||
if len(drained) != 1 || drained[0].Path != "/a.png" {
|
||
t.Fatalf("Drain = %+v", drained)
|
||
}
|
||
if again := box.Drain("sess-a"); again != nil {
|
||
t.Fatalf("第二次 Drain 应当为空(取走即消费):%+v", again)
|
||
}
|
||
}
|
||
|
||
// TestShotOutbox_NoSessionIsHonest 钉住「拿不到会话 id 就不入箱」:子代理 / 未接编排器的
|
||
// 调用必须如实报 no-session(图仍在盘上,模型按路径自己读),而不是丢进一个全局格子。
|
||
//
|
||
// ⚠️ 这里只测「没有会话」这一档:把会话 id 播种到 ctx 上的**唯一**生产者是
|
||
// `loop.Run`(`withLoopSession` 不导出),因此「有会话时真的入箱」由组合根的真实循环
|
||
// 用例覆盖(`cmd/cyrene/wire_browser_context_test.go`),本包不伪造那条通道。
|
||
func TestShotOutbox_NoSessionIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
box := NewShotOutbox()
|
||
if got := box.Put("", fakeShot("/x.png")); got != TurnAttachmentNoSession {
|
||
t.Fatalf("空会话 id 的 Put = %q, want %q", got, TurnAttachmentNoSession)
|
||
}
|
||
if got := box.Put(" ", fakeShot("/x.png")); got != TurnAttachmentNoSession {
|
||
t.Fatalf("空白会话 id 的 Put = %q, want %q", got, TurnAttachmentNoSession)
|
||
}
|
||
if got := box.PutForContext(context.Background(), fakeShot("/x.png")); got != TurnAttachmentNoSession {
|
||
t.Fatalf("ctx 上没有会话时的 PutForContext = %q, want %q", got, TurnAttachmentNoSession)
|
||
}
|
||
if n := box.Pending(""); n != 0 {
|
||
t.Fatalf("空会话不该攒下任何东西:Pending=%d", n)
|
||
}
|
||
}
|
||
|
||
// TestShotOutbox_BoundedAndTTL 钉住有界与寿命:超过上限丢**最旧**的一张并如实报
|
||
// outbox-full;超过 TTL 的截图不再送出(会话可能早已结束)。
|
||
func TestShotOutbox_BoundedAndTTL(t *testing.T) {
|
||
t.Parallel()
|
||
box := NewShotOutbox()
|
||
base := time.Now()
|
||
box.now = func() time.Time { return base }
|
||
for i := 0; i < maxPendingShots; i++ {
|
||
if got := box.Put("s", fakeShot(string(rune('a'+i)))); got != TurnAttachmentQueued {
|
||
t.Fatalf("第 %d 张 Put = %q", i, got)
|
||
}
|
||
}
|
||
if got := box.Put("s", fakeShot("overflow")); got != TurnAttachmentFull {
|
||
t.Fatalf("超上限的 Put = %q, want %q", got, TurnAttachmentFull)
|
||
}
|
||
got := box.Drain("s")
|
||
if len(got) != maxPendingShots {
|
||
t.Fatalf("攒着的张数 = %d, want %d", len(got), maxPendingShots)
|
||
}
|
||
if got[len(got)-1].Path != "overflow" {
|
||
t.Errorf("最新的一张应当留下:%+v", got)
|
||
}
|
||
// TTL:过期的格子被清掉(文件仍在盘上,只是不再送出)。
|
||
box.Put("t", fakeShot("/old.png"))
|
||
box.now = func() time.Time { return base.Add(shotOutboxTTL + time.Minute) }
|
||
if n := box.Pending("t"); n != 0 {
|
||
t.Errorf("过期截图仍在箱里:Pending=%d", n)
|
||
}
|
||
}
|
||
|
||
// TestTool_ScreenshotQueuesShotForTheTurn 钉住工具面的接线:带会话 ctx 的截图动作把图
|
||
// 放进箱子并如实报 queued,同时**仍然**返回路径与统计(模型/面板都能按路径再读)。
|
||
//
|
||
// 会话来自 `loop.Run`(本包造不出来),因此这里经**箱子本身**验证注入点:工具用的是
|
||
// 同一个箱子实例(`WithShotOutbox` 注入 → `ShotOutbox()` 取回),真实循环链路由组合根
|
||
// 用例端到端覆盖。
|
||
func TestTool_ScreenshotQueuesShotForTheTurn(t *testing.T) {
|
||
t.Parallel()
|
||
drv := &fakeDriver{available: true, shot: fakeShot("/shots/s1.png")}
|
||
injected := NewShotOutbox()
|
||
injected.Put("sess-shot", fakeShot("/preset.png"))
|
||
tool := New(drv, WithShotOutbox(injected))
|
||
|
||
if tool.ShotOutbox() != injected {
|
||
t.Fatal("WithShotOutbox 注入的箱子没被采用:装配钩子将取不到截图")
|
||
}
|
||
// 工具在**没有会话**的 ctx 下动作:图仍落盘,附件状态如实报 no-session。
|
||
res, err := tool.Execute(context.Background(), map[string]any{"action": "screenshot"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
m, ok := res.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("结果类型 = %T", res)
|
||
}
|
||
if got, _ := m["turnAttachment"].(string); got != TurnAttachmentNoSession {
|
||
t.Fatalf("turnAttachment = %q, want %q", got, TurnAttachmentNoSession)
|
||
}
|
||
if got, _ := m["path"].(string); got != "/shots/s1.png" {
|
||
t.Fatalf("path = %q", got)
|
||
}
|
||
if hint, _ := m["hint"].(string); hint == "" {
|
||
t.Error("hint 为空:模型不知道这张图进没进上下文")
|
||
}
|
||
// 预先放进箱子的那张仍在(本次调用没有会话,因此没有消费它)。
|
||
if got := tool.ShotOutbox().Drain("sess-shot"); len(got) != 1 || got[0].Path != "/preset.png" {
|
||
t.Fatalf("箱子里的截图 = %+v", got)
|
||
}
|
||
}
|
||
|
||
// TestTool_ScreenshotWithoutSessionIsHonest 钉住没有会话时的行为:动作照样成功(图落盘),
|
||
// 但 `turnAttachment` 如实报 no-session,且提示文案改为「按路径自己读」。
|
||
func TestTool_ScreenshotWithoutSessionIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
drv := &fakeDriver{available: true, shot: fakeShot("/shots/s2.png")}
|
||
tool := New(drv)
|
||
|
||
res, err := tool.Execute(context.Background(), map[string]any{"action": "screenshot"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
m := res.(map[string]any)
|
||
if got, _ := m["turnAttachment"].(string); got != TurnAttachmentNoSession {
|
||
t.Fatalf("turnAttachment = %q, want %q", got, TurnAttachmentNoSession)
|
||
}
|
||
if hint, _ := m["hint"].(string); hint == "" || !strings.Contains(hint, "NOT attached") {
|
||
t.Errorf("没有会话时的提示必须说清「没进上下文」:%q", hint)
|
||
}
|
||
}
|
||
|
||
// TestTool_StateExposesFactsWithoutDriver 钉住面板取数的兜底:驱动不支持事实面
|
||
// (假驱动)时返回零值快照,而不是 panic —— 面板据此显示「未知」。
|
||
func TestTool_StateExposesFactsWithoutDriver(t *testing.T) {
|
||
t.Parallel()
|
||
if got := New(nil).State(); got.Available {
|
||
t.Errorf("nil 驱动的事实面 = %+v,应当是零值", got)
|
||
}
|
||
if got := (*Tool)(nil).State(); got.Available {
|
||
t.Errorf("nil 工具的事实面 = %+v", got)
|
||
}
|
||
}
|