- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
189 lines
6.3 KiB
Go
189 lines
6.3 KiB
Go
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, ¶ms)
|
||
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("连接关闭后等待者没被叫醒")
|
||
}
|
||
}
|