Files
homeagent-sdk/example/browser/chromedp_compat_test.go
JianFeeeee 0b497f4876 fix(browser)!: 升级 chromedp v0.9.5→v0.20.1,修视口截图必挂 (#1)
用户报告截图不对(此前已改为落盘返回路径)。查下去发现**两个叠加的问题**,
其中一个是既有的、与上次改动无关的严重缺陷。

## ① 协议解析失败:chromedp v0.9.5 不认 Chrome 154

```
ERROR: could not unmarshal event: parse error: expected string near offset 411 of 'cookiePart...'
```

cdproto 把 `partitionKey` 定义为 `string`,而 Chrome 154 已改成对象
(`cookiePartitionKey`)。新版 cdproto 定义了 `CookiePartitionKey` 结构体,
且 `UnmarshalJSON` **双向兼容**(字符串 → 结构体,旧浏览器也不坏)。

副作用:`Navigate` 从 2.5s 降到 85ms(解析错误消失,不再反复重试)。

## ② 视口截图在 headless 下**永远挂住**(既有缺陷,不是升级引入)

`Screenshot(sel)` 内部是 `QueryAfter` + `NodeVisible`,而 NodeVisible 在
headless Chromium 上永远不满足 —— 两步单独看都成功,但它一直等下去:

```
Do(Query body)          ✓        Do(WaitReady body)      ✓
Do(WaitVisible body)    ✗挂住     Do(WaitVisible html)    ✗挂住
Run(Screenshot html)    ✗挂住     Run(FullScreenshot)     ✓
Run(CaptureScreenshot)  ✓(70KB)
```

**决定性对照:旧版 chromedp v0.9.5 在同一个后端上同样挂。**
所以这不是升级引入的。生产日志也佐证:

- 成功的 8 次 —— agent 都传了 `full:true`(走 FullScreenshot)
- 超时的 5 次 —— 都是默认分支(走 Screenshot("body"))

且 `NodeVisible` 的两步实测都正常(`dom.GetBoxModel` 300µs 成功、
`visibleJS` 返回 true / offsetWidth=480),就是等待机制本身不返回。
没有深挖到 chromedp 内部的根因,但**换用 `CaptureScreenshot` 完全绕开这条链**,
且语义更对:「不传 full 就只截当前视口」本来就是 API 文档的含义。

## 迁移方式:兼容层收敛,业务代码最小改动

v0.20 是**泛型重写**(`Action[T]` 返回值而不填指针),25 处调用点受影响。
新增 `chromedp_compat.go` 把新 API 收敛成几个小助手
(`chromedpDo` / `newRemoteAllocator` / `chromedpCaptureScreenshot` …),
业务代码保持旧写法。

为何不逐处改:20+ 处机械改写会把 diff 淹没,真正要审的
「行为变了没有」反而看不清。

`NewRemoteAllocator` 在 v0.20 移到了独立子模块 `remote`(核心模块现在
只用标准库 + cdproto,需要 websocket 的东西全部搬出去)。

## 顺带修掉 go.mod 的重复 replace

`go mod tidy` 清掉了 4 条重复的 `replace ... => ../../`(hmapdev 往已有
go.mod 追加却不去重导致的)。Go 容忍它,但 `go mod tidy` 会报错。

## 判据(可杀变异,实测)

`chromedp_compat_test.go`:
- 视口截图必须走 `CaptureScreenshot`,不得退回 `Screenshot(sel)`
  (改回挂住的写法 → FAIL ×2)
- 兼容层关键助手都在(去掉 CaptureScreenshot → build FAIL)
- 无旧式「传指针接收截图」残留

全部 20 个 example `go vet` 通过;SDK 根 module build/test 通过。
生产实测:agent 做视口截图返回
`/home/newqqagent/browser_screenshots/shot-20261011-104225.610-browser_1.png`
(1280×713 PNG / 70518 字节),不再超时。

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-11 10:45:41 +08:00

108 lines
3.9 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 main
import (
"os"
"strings"
"testing"
)
// 视口截图**必须**走 CaptureScreenshot,不能用 Screenshot(sel)。
//
// # 为什么这条判据存在(2026-10-11 实测)
//
// chromedp 的 Screenshot(sel) 内部是 QueryAfter + **NodeVisible**,
// 而 NodeVisible 在本机的 headless Chromium 上**永远不会满足** —— 它先
// 用 dom.GetBoxModel 拿盒子(实测 300µs 就成功),再用 visibleJS 判定
// (实测返回 true,offsetWidth=480),两步都对,但它仍一直等下去。
//
// 同一后端上的完整对照(实测):
//
// Do(Query body) ✓ Do(WaitReady body) ✓
// Do(WaitVisible body) ✗挂住 Do(WaitVisible html) ✗挂住
// Run(Screenshot html) ✗挂住 Run(FullScreenshot) ✓
// Run(CaptureScreenshot) ✓
//
// 且**旧版 chromedp v0.9.5 同样挂** —— 这不是升级引入的,是既有缺陷。
// 生产日志佐证:成功的都是 agent 传了 full:true(走 FullScreenshot),
// 超时的都是默认分支(走 Screenshot("body"))。
//
// 所以这条判据守的是:不许把 CaptureScreenshot 改回 Screenshot(sel)。
func TestViewportScreenshotUsesCaptureScreenshot(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "func (p *Plugin) handleScreenshot(")
if i < 0 {
t.Fatal("找不到 handleScreenshot")
}
seg := s[i:]
if j := strings.Index(seg, "\nfunc (p *Plugin) saveScreenshot("); j > 0 {
seg = seg[:j]
}
// ① 视口分支必须用 CaptureScreenshot
if !strings.Contains(seg, "chromedpCaptureScreenshot()") {
t.Error("视口截图必须走 chromedpCaptureScreenshot()(=CaptureScreenshot):\n" +
" 用 Screenshot(\"body\") 会因 NodeVisible 在 headless 下永不满足而挂到超时")
}
// ② 不得再出现挂住的那种写法
if strings.Contains(seg, `chromedpScreenshot(`) {
t.Error("handleScreenshot 里出现了 chromedpScreenshot(sel) —— " +
"该动作在 headless 下必挂(实测),改用 chromedpCaptureScreenshot()")
}
// ③ full 分支仍走 FullScreenshot(那条一直是好的)
if !strings.Contains(seg, "chromedpFullScreenshot(") {
t.Error("full=true 应走 chromedpFullScreenshot(FullScreenshot 实测正常)")
}
}
// TestCompatLayerExists 确认兼容层还在(迁移收敛点)。
//
// 这一层是为了让 20+ 处调用点的迁移不淹没 diff。若有人删掉它而没把
// 调用点改对,编译会红;但若有人把它改成直接透传错误 API,就只会
// 在运行时挂住 —— 所以这里钉住关键几个。
func TestCompatLayerExists(t *testing.T) {
src, err := os.ReadFile("chromedp_compat.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
for _, need := range []string{
"func chromedpDo(",
"func newRemoteAllocator(",
"func chromedpCaptureScreenshot(",
"func chromedpFullScreenshot(",
"func chromedpTitle(",
} {
if !strings.Contains(s, need) {
t.Errorf("chromedp 兼容层缺少 %s", need)
}
}
// 必须用 remote 子模块的分配器(v0.20 把它移出去了)
if !strings.Contains(s, "remote.NewAllocator") {
t.Error("newRemoteAllocator 必须用 remote.NewAllocator(v0.20 移出核心模块)")
}
}
// TestNoLegacyPointerScreenshot 确认没有残留的旧式「传指针接收」写法。
//
// v0.20 的截图动作是**返回值**,传 &buf 会直接编译失败 —— 但这条判据
// 仍值得留着:它锁的是「值语义」这个契约,而不是某次编译结果。
func TestNoLegacyPointerScreenshot(t *testing.T) {
for _, f := range []string{"plugin.go", "chromedp_compat.go"} {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
s := string(b)
if strings.Contains(s, "Screenshot(&") || strings.Contains(s, "FullScreenshot(&") {
t.Errorf("%s 仍用旧式传指针接收截图(v0.20 已改为返回值)", f)
}
}
}