- channelbridge: 拨号失败的 WARN 不再带原始 dial 错误(含 ?access_token=), URLCause 剥壳 + 内嵌地址 MaskQuery 打码 + HostOf 保留目标来源;控制流零改动 - browser/cdp.go: CDP error.message 只有像协议枚举(ErrorToken)才出镜,其余只留字节数 - mcp: 第三方 server 的错误文本默认不带,只留 server/工具/JSON-RPC 码/message 长度, 分类链(errors.Is/As、context.Canceled)经 Unwrap 保留 - channel/adapter/flowgate: 审查输出摘要由 40 字节改 40 rune(防切 UTF-8)并过 RedactText 四处均补了会随回退变红的用例(变异已实测)。
235 lines
8.5 KiB
Go
235 lines
8.5 KiB
Go
package browser
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"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_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
|
||
// (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
|
||
//
|
||
// 为什么 message 不能出镜:它是**上游自由文本** —— Chrome 与各类兼容实现可以往里面
|
||
// 放它刚被要求操作的东西(表达式、地址、页面内容)。只有像协议枚举的短串
|
||
// (trace.ErrorToken)才保留,其余只留字节数。
|
||
//
|
||
// 夹具刻意用**带空格的散文 + 会被回显的页面文本**:真正的 CDP message 就长这样
|
||
// ("Cannot find context with specified id"),而像令牌的单字("boom")本来就会过
|
||
// 守卫 —— 用例不该拿它当「自由文本」。
|
||
//
|
||
// 变异判据:把 cdpError.Error() 换回 `fmt.Sprintf("CDP %d: %s", e.Code, e.Message)`
|
||
// ⇒ 哨兵文本出现在错误串里,本用例红。
|
||
func TestCDPClient_ProtocolErrorKeepsNoFreeText(t *testing.T) {
|
||
const message = "Cannot resolve context for <script>PAGE-ECHO-SECRET</script>"
|
||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||
return nil, &cdpError{Code: -32000, Message: message}
|
||
})
|
||
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 {
|
||
t.Fatal("协议错误应当返回 error")
|
||
}
|
||
if strings.Contains(err.Error(), "PAGE-ECHO-SECRET") {
|
||
t.Fatalf("CDP 的自由文本进了错误串:%v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "CDP -32000") {
|
||
t.Errorf("错误没带上协议码: %v", err)
|
||
}
|
||
if want := fmt.Sprintf("message %d bytes withheld", len(message)); !strings.Contains(err.Error(), want) {
|
||
t.Errorf("错误没带上被扣下文本的长度(期望 %q): %v", want, err)
|
||
}
|
||
// 方法名是代码给的名字,必须仍在 —— 扣掉 message 之后诊断面靠它定位。
|
||
if !strings.Contains(err.Error(), "Runtime.evaluate") {
|
||
t.Errorf("错误丢了方法名: %v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "CDP 调用失败") {
|
||
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestCDPClient_ProtocolErrorKeepsProtocolToken 是「协议枚举仍可读」的正向证据:
|
||
// 取值像令牌(≤64 字节、仅 [A-Za-z0-9_.-])的 message 原样保留。
|
||
//
|
||
// 变异判据:把 cdpError.Error() 改成「message 一律不带」⇒ 本用例红。
|
||
func TestCDPClient_ProtocolErrorKeepsProtocolToken(t *testing.T) {
|
||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||
return nil, &cdpError{Code: -32602, Message: "context_not_found"}
|
||
})
|
||
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(), "context_not_found") {
|
||
t.Fatalf("协议枚举形状的 message 应保留:%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("连接关闭后等待者没被叫醒")
|
||
}
|
||
}
|