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

172 lines
7.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
// 本文件钉住 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)
}
}