Files
Cyrene_v2/internal/capability/browser/cdp_test.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): B1 接线 + B2 薄层工具面(V5)
- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

189 lines
6.3 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
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
// fakeCDP 是一个「协议形状对」的假 CDP server:它按方法名应答,用来在没有浏览器的
// 机器上覆盖客户端的连接/关联/错误/取消四条路径。
type fakeCDP struct {
srv *httptest.Server
// base 是 HTTP 基地址(`pageTargetWS` 与 `/json/version` 用它)。
base string
// pageWS 是 page target 的 WebSocket 地址(`newCDPClient` 用它)。
pageWS string
}
// newFakeCDP 起一个假 CDP:/json/list 返回一个 page target,/devtools/page/1 是 WS。
// handler 收到 (method, params),返回 (result, cdpError)。
func newFakeCDP(t *testing.T, handler func(method string, params map[string]any) (any, *cdpError)) *fakeCDP {
t.Helper()
f := &fakeCDP{}
mux := http.NewServeMux()
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
mux.HandleFunc("/devtools/page/1", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
for {
_, data, err := conn.ReadMessage()
if err != nil {
return
}
var msg cdpMessage
if err := json.Unmarshal(data, &msg); err != nil {
return
}
var params map[string]any
_ = json.Unmarshal(msg.Params, &params)
result, cdpErr := handler(msg.Method, params)
out := map[string]any{"id": msg.ID}
if cdpErr != nil {
out["error"] = cdpErr
} else {
out["result"] = result
}
payload, _ := json.Marshal(out)
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return
}
}
})
mux.HandleFunc("/json/list", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"type":"page","url":"about:blank","webSocketDebuggerUrl":"` + f.pageWS + `"}]`))
})
mux.HandleFunc("/json/version", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0","webSocketDebuggerUrl":"` + f.pageWS + `"}`))
})
f.srv = httptest.NewServer(mux)
f.base = f.srv.URL
f.pageWS = "ws://" + strings.TrimPrefix(f.srv.URL, "http://") + "/devtools/page/1"
t.Cleanup(f.srv.Close)
return f
}
// TestCDPClient_CallRoundTrip 钉住最基本的一条:命令发出去、按 id 回来的结果能解出来。
func TestCDPClient_CallRoundTrip(t *testing.T) {
f := newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
if method != "Page.getFrameTree" {
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
}
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
})
wsURL, err := pageTargetWS(context.Background(), f.base)
if err != nil {
t.Fatalf("pageTargetWS: %v", err)
}
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
var tree struct {
FrameTree struct {
Frame struct {
ID string `json:"id"`
} `json:"frame"`
} `json:"frameTree"`
}
if err := c.Call(context.Background(), "Page.getFrameTree", nil, &tree); err != nil {
t.Fatalf("Call: %v", err)
}
if tree.FrameTree.Frame.ID != "frame-1" {
t.Errorf("frame id = %q, want frame-1", tree.FrameTree.Frame.ID)
}
}
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此
// 判定「连接级问题 ⇒ 丢会话重起」)。
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32000, Message: "boom"}
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err)
}
if !strings.Contains(err.Error(), "CDP 调用失败") {
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
}
}
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
time.Sleep(2 * time.Second)
return map[string]any{}, nil
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
start := time.Now()
if err := c.Call(ctx, "Page.navigate", nil, nil); err == nil {
t.Fatal("ctx 到期应当返回错误")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("取消后仍等了 %v,说明没接 ctx", elapsed)
}
}
// TestPageTargetWS_NoPageTarget 钉住「连上了却没有 page target」被如实报出来
// (不是含糊成一个 nil 端点)。
func TestPageTargetWS_NoPageTarget(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"type":"service_worker","webSocketDebuggerUrl":"ws://x/1"}]`))
}))
defer srv.Close()
if _, err := pageTargetWS(context.Background(), srv.URL); err == nil {
t.Fatal("没有 page target 时应当报错")
}
}
// TestCDPClient_CloseFailsPendingCalls 钉住连接断开后等待者被叫醒(否则一次断线
// 会把调用方永久挂住)。
func TestCDPClient_CloseFailsPendingCalls(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
time.Sleep(3 * time.Second)
return map[string]any{}, nil
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
done := make(chan error, 1)
go func() { done <- c.Call(context.Background(), "Page.navigate", nil, nil) }()
time.Sleep(50 * time.Millisecond)
_ = c.Close()
select {
case err := <-done:
if err == nil {
t.Fatal("连接关闭后应返回错误")
}
case <-time.After(2 * time.Second):
t.Fatal("连接关闭后等待者没被叫醒")
}
}