feat(browser): B1 接线 + B2 薄层工具面(V5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
AskaEth committed 2026-10-06 20:25:16 +08:00
1 parent ea150ceea9
commit f1fbc249fd
21 files changed
+3579

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
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("连接关闭后等待者没被叫醒")
}
}