| 1 | package cdp |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "image" |
| 10 | "image/color" |
| 11 | "image/png" |
| 12 | "net/http" |
| 13 | "net/http/httptest" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/gorilla/websocket" |
| 20 | ) |
| 21 | |
| 22 | // fakeBrowser answers the DevTools subset this package speaks. Tests script it |
| 23 | // by replacing entries in handlers or by flipping the page-script state. |
| 24 | type fakeBrowser struct { |
| 25 | t *testing.T |
| 26 | srv *httptest.Server |
| 27 | |
| 28 | mu sync.Mutex |
| 29 | ws *websocket.Conn |
| 30 | calls []string |
| 31 | args map[string][]json.RawMessage |
| 32 | handlers map[string]func(params json.RawMessage) (any, *protocolError) |
| 33 | userSeq int64 |
| 34 | tree string |
| 35 | refs map[string]bool |
| 36 | nextID int |
| 37 | } |
| 38 | |
| 39 | func newFakeBrowser(t *testing.T) *fakeBrowser { |
| 40 | t.Helper() |
| 41 | f := &fakeBrowser{ |
| 42 | t: t, args: map[string][]json.RawMessage{}, |
| 43 | handlers: map[string]func(json.RawMessage) (any, *protocolError){}, |
| 44 | tree: "- button \"Save\" [ref=e1]", |
| 45 | refs: map[string]bool{"e1": true}, |
| 46 | } |
| 47 | mux := http.NewServeMux() |
| 48 | mux.HandleFunc("/json/version", func(w http.ResponseWriter, r *http.Request) { |
| 49 | _ = json.NewEncoder(w).Encode(map[string]string{ |
| 50 | "Browser": "HeadlessChrome/fake", |
| 51 | "webSocketDebuggerUrl": "ws" + strings.TrimPrefix(f.srv.URL, "http") + "/devtools/browser/fake", |
| 52 | }) |
| 53 | }) |
| 54 | mux.HandleFunc("/devtools/browser/fake", f.serveSocket) |
| 55 | f.srv = httptest.NewServer(mux) |
| 56 | t.Cleanup(f.srv.Close) |
| 57 | return f |
| 58 | } |
| 59 | |
| 60 | func (f *fakeBrowser) serveSocket(w http.ResponseWriter, r *http.Request) { |
| 61 | up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} |
| 62 | ws, err := up.Upgrade(w, r, nil) |
| 63 | if err != nil { |
| 64 | return |
| 65 | } |
| 66 | f.mu.Lock() |
| 67 | f.ws = ws |
| 68 | f.mu.Unlock() |
| 69 | for { |
| 70 | _, data, err := ws.ReadMessage() |
| 71 | if err != nil { |
| 72 | return |
| 73 | } |
| 74 | var msg message |
| 75 | if err := json.Unmarshal(data, &msg); err != nil { |
| 76 | continue |
| 77 | } |
| 78 | f.serve(ws, msg) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func (f *fakeBrowser) serve(ws *websocket.Conn, msg message) { |
| 83 | f.mu.Lock() |
| 84 | f.calls = append(f.calls, msg.Method) |
| 85 | f.args[msg.Method] = append(f.args[msg.Method], msg.Params) |
| 86 | handler := f.handlers[msg.Method] |
| 87 | f.mu.Unlock() |
| 88 | |
| 89 | var ( |
| 90 | result any |
| 91 | fail *protocolError |
| 92 | ) |
| 93 | if handler != nil { |
| 94 | result, fail = handler(msg.Params) |
| 95 | } else { |
| 96 | result, fail = f.builtin(msg) |
| 97 | } |
| 98 | reply := message{ID: msg.ID, SessionID: msg.SessionID} |
| 99 | if fail != nil { |
| 100 | reply.Error = fail |
| 101 | } else { |
| 102 | raw, err := json.Marshal(result) |
| 103 | if err != nil { |
| 104 | f.t.Errorf("fake: encode %s result: %v", msg.Method, err) |
| 105 | return |
| 106 | } |
| 107 | reply.Result = raw |
| 108 | } |
| 109 | f.write(ws, reply) |
| 110 | if msg.Method == "Page.navigate" || msg.Method == "Page.reload" || msg.Method == "Page.navigateToHistoryEntry" { |
| 111 | f.emit(msg.SessionID, "Page.frameNavigated", map[string]any{"frame": map[string]any{"id": "frame-1", "url": "https://example.test/next"}}) |
| 112 | f.emit(msg.SessionID, "Page.frameStoppedLoading", map[string]any{"frameId": "frame-1"}) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // builtin answers the commands every test needs the same way. |
| 117 | func (f *fakeBrowser) builtin(msg message) (any, *protocolError) { |
| 118 | switch msg.Method { |
| 119 | case "Target.createTarget": |
| 120 | f.mu.Lock() |
| 121 | f.nextID++ |
| 122 | id := fmt.Sprintf("target-%d", f.nextID) |
| 123 | f.mu.Unlock() |
| 124 | return map[string]any{"targetId": id}, nil |
| 125 | case "Target.attachToTarget": |
| 126 | var in struct { |
| 127 | TargetID string `json:"targetId"` |
| 128 | } |
| 129 | _ = json.Unmarshal(msg.Params, &in) |
| 130 | return map[string]any{"sessionId": "session-" + in.TargetID}, nil |
| 131 | case "Target.createBrowserContext": |
| 132 | return map[string]any{"browserContextId": "context-1"}, nil |
| 133 | case "Target.getTargetInfo": |
| 134 | return map[string]any{"targetInfo": map[string]any{"url": "https://example.test/", "title": "Example"}}, nil |
| 135 | case "Page.getFrameTree": |
| 136 | return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1", "url": "https://example.test/"}}}, nil |
| 137 | case "Page.createIsolatedWorld": |
| 138 | return map[string]any{"executionContextId": 7}, nil |
| 139 | case "Page.getLayoutMetrics": |
| 140 | return map[string]any{ |
| 141 | "cssContentSize": map[string]any{"width": 800, "height": 2400}, |
| 142 | "cssVisualViewport": map[string]any{"clientWidth": 800, "clientHeight": 600, "pageX": 0, "pageY": 0}, |
| 143 | }, nil |
| 144 | case "Page.getNavigationHistory": |
| 145 | return map[string]any{"currentIndex": 1, "entries": []map[string]any{{"id": 10}, {"id": 11}}}, nil |
| 146 | case "Page.captureScreenshot": |
| 147 | return map[string]any{"data": fakePNG(f.t)}, nil |
| 148 | case "Runtime.evaluate": |
| 149 | return f.evaluate(msg.Params) |
| 150 | } |
| 151 | return map[string]any{}, nil |
| 152 | } |
| 153 | |
| 154 | // evaluate stands in for the isolated world: it recognises each __rx call the |
| 155 | // executor makes and answers with the state the test set up. |
| 156 | func (f *fakeBrowser) evaluate(params json.RawMessage) (any, *protocolError) { |
| 157 | var in struct { |
| 158 | Expression string `json:"expression"` |
| 159 | ReturnByValue bool `json:"returnByValue"` |
| 160 | } |
| 161 | _ = json.Unmarshal(params, &in) |
| 162 | f.mu.Lock() |
| 163 | defer f.mu.Unlock() |
| 164 | expr := in.Expression |
| 165 | switch { |
| 166 | case strings.HasPrefix(expr, "__rx.state()"): |
| 167 | return value(map[string]any{"userSeq": f.userSeq, "url": "https://example.test/", "title": "Example", "ready": "complete"}), nil |
| 168 | case strings.HasPrefix(expr, "__rx.snapshot("): |
| 169 | return value(map[string]any{ |
| 170 | "url": "https://example.test/", "title": "Example", "tree": f.tree, |
| 171 | "refs": len(f.refs), "userSeq": f.userSeq, "truncated": false, |
| 172 | }), nil |
| 173 | case strings.HasPrefix(expr, "__rx.window("): |
| 174 | return value(f.userSeq), nil |
| 175 | case strings.HasPrefix(expr, "__rx.rect("): |
| 176 | if !f.refs[refArg(expr)] { |
| 177 | return value(nil), nil |
| 178 | } |
| 179 | return value(map[string]any{"x": 40, "y": 60, "width": 80, "height": 20, "tag": "button"}), nil |
| 180 | case strings.HasPrefix(expr, "__rx.focus("): |
| 181 | return value(map[string]any{"ok": f.refs[refArg(expr)], "reason": "the element refused focus"}), nil |
| 182 | case strings.HasPrefix(expr, "__rx.select("): |
| 183 | return value(map[string]any{"ok": true, "selected": []string{"one"}}), nil |
| 184 | case strings.HasPrefix(expr, "__rx.element("): |
| 185 | if !f.refs[refArg(expr)] { |
| 186 | return map[string]any{"result": map[string]any{"type": "object", "subtype": "null"}}, nil |
| 187 | } |
| 188 | return map[string]any{"result": map[string]any{"type": "object", "objectId": "object-1"}}, nil |
| 189 | } |
| 190 | return map[string]any{"result": map[string]any{"type": "undefined"}}, nil |
| 191 | } |
| 192 | |
| 193 | func value(v any) map[string]any { |
| 194 | return map[string]any{"result": map[string]any{"type": "object", "value": v}} |
| 195 | } |
| 196 | |
| 197 | // refArg pulls the quoted ref out of an __rx call such as __rx.rect("e1"). |
| 198 | func refArg(expr string) string { |
| 199 | _, rest, ok := strings.Cut(expr, `"`) |
| 200 | if !ok { |
| 201 | return "" |
| 202 | } |
| 203 | ref, _, ok := strings.Cut(rest, `"`) |
| 204 | if !ok { |
| 205 | return "" |
| 206 | } |
| 207 | return ref |
| 208 | } |
| 209 | |
| 210 | func (f *fakeBrowser) write(ws *websocket.Conn, msg message) { |
| 211 | data, err := json.Marshal(msg) |
| 212 | if err != nil { |
| 213 | f.t.Errorf("fake: encode reply: %v", err) |
| 214 | return |
| 215 | } |
| 216 | f.mu.Lock() |
| 217 | defer f.mu.Unlock() |
| 218 | if err := ws.WriteMessage(websocket.TextMessage, data); err != nil { |
| 219 | return |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func (f *fakeBrowser) emit(session, method string, params any) { |
| 224 | raw, err := json.Marshal(params) |
| 225 | if err != nil { |
| 226 | f.t.Errorf("fake: encode %s params: %v", method, err) |
| 227 | return |
| 228 | } |
| 229 | f.mu.Lock() |
| 230 | ws := f.ws |
| 231 | f.mu.Unlock() |
| 232 | if ws == nil { |
| 233 | return |
| 234 | } |
| 235 | f.write(ws, message{Method: method, Params: raw, SessionID: session}) |
| 236 | } |
| 237 | |
| 238 | // setHandler overrides one command for the rest of the test. |
| 239 | func (f *fakeBrowser) setHandler(method string, fn func(json.RawMessage) (any, *protocolError)) { |
| 240 | f.mu.Lock() |
| 241 | f.handlers[method] = fn |
| 242 | f.mu.Unlock() |
| 243 | } |
| 244 | |
| 245 | func (f *fakeBrowser) takeOver() { |
| 246 | f.mu.Lock() |
| 247 | f.userSeq++ |
| 248 | f.mu.Unlock() |
| 249 | } |
| 250 | |
| 251 | func (f *fakeBrowser) countCalls(method string) int { |
| 252 | f.mu.Lock() |
| 253 | defer f.mu.Unlock() |
| 254 | n := 0 |
| 255 | for _, call := range f.calls { |
| 256 | if call == method { |
| 257 | n++ |
| 258 | } |
| 259 | } |
| 260 | return n |
| 261 | } |
| 262 | |
| 263 | func (f *fakeBrowser) lastArgs(method string) json.RawMessage { |
| 264 | f.mu.Lock() |
| 265 | defer f.mu.Unlock() |
| 266 | list := f.args[method] |
| 267 | if len(list) == 0 { |
| 268 | return nil |
| 269 | } |
| 270 | return list[len(list)-1] |
| 271 | } |
| 272 | |
| 273 | func fakePNG(t *testing.T) string { |
| 274 | t.Helper() |
| 275 | img := image.NewRGBA(image.Rect(0, 0, 4, 3)) |
| 276 | img.Set(0, 0, color.RGBA{R: 255, A: 255}) |
| 277 | var buf bytes.Buffer |
| 278 | if err := png.Encode(&buf, img); err != nil { |
| 279 | t.Fatalf("encode fake png: %v", err) |
| 280 | } |
| 281 | return base64.StdEncoding.EncodeToString(buf.Bytes()) |
| 282 | } |
| 283 | |
| 284 | // newTestExecutor attaches an executor to the fake browser with artifacts in |
| 285 | // the test's own directory. |
| 286 | func newTestExecutor(t *testing.T, f *fakeBrowser) *Executor { |
| 287 | t.Helper() |
| 288 | return newTestExecutorWithRoots(t, f) |
| 289 | } |
| 290 | |
| 291 | // newTestExecutorWithRoots is newTestExecutor with the directories |
| 292 | // browser_upload may read from. |
| 293 | func newTestExecutorWithRoots(t *testing.T, f *fakeBrowser, uploadRoots ...string) *Executor { |
| 294 | t.Helper() |
| 295 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) |
| 296 | defer cancel() |
| 297 | exec, err := New(ctx, Options{ |
| 298 | Endpoint: f.srv.URL, ArtifactDir: t.TempDir(), |
| 299 | NavigateTimeout: 5 * time.Second, UploadRoots: uploadRoots, |
| 300 | }) |
| 301 | if err != nil { |
| 302 | t.Fatalf("new executor: %v", err) |
| 303 | } |
| 304 | t.Cleanup(exec.Shutdown) |
| 305 | return exec |
| 306 | } |
| 307 |