Files
Cyrene_v2/internal/capability/browser/cdp_test.go
T
AskaEth d6493d79f4 fix(upstream): M51 收敛剩余上游自由文本出口 - CDP/MCP/flowgate/channelbridge WARN
- 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

四处均补了会随回退变红的用例(变异已实测)。
2026-10-26 19:44:22 +08:00

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