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("连接关闭后等待者没被叫醒") } }