Skip to main content

roboticus_browser/
actions.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Value, json};
3use tracing::debug;
4
5use crate::session::CdpSession;
6
7/// Maximum allowed length (in characters) for `BrowserAction::Evaluate` expressions.
8const MAX_EXPRESSION_LENGTH: usize = 100_000;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "action")]
12pub enum BrowserAction {
13    Navigate { url: String },
14    Click { selector: String },
15    Type { selector: String, text: String },
16    Screenshot,
17    Pdf,
18    Evaluate { expression: String },
19    GetCookies,
20    ClearCookies,
21    ReadPage,
22    GoBack,
23    GoForward,
24    Reload,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ActionResult {
29    pub action: String,
30    pub success: bool,
31    pub data: Option<Value>,
32    pub error: Option<String>,
33}
34
35impl ActionResult {
36    #[must_use]
37    pub fn ok(action: &str, data: Value) -> Self {
38        Self {
39            action: action.to_string(),
40            success: true,
41            data: Some(data),
42            error: None,
43        }
44    }
45
46    #[must_use]
47    pub fn err(action: &str, error: String) -> Self {
48        Self {
49            action: action.to_string(),
50            success: false,
51            data: None,
52            error: Some(error),
53        }
54    }
55}
56
57/// Executes `BrowserAction` variants against a live CDP session.
58pub struct ActionExecutor;
59
60impl ActionExecutor {
61    pub async fn execute(session: &CdpSession, action: &BrowserAction) -> ActionResult {
62        match action {
63            BrowserAction::Navigate { url } => Self::navigate(session, url).await,
64            BrowserAction::Click { selector } => Self::click(session, selector).await,
65            BrowserAction::Type { selector, text } => {
66                Self::type_text(session, selector, text).await
67            }
68            BrowserAction::Screenshot => Self::screenshot(session).await,
69            BrowserAction::Pdf => Self::pdf(session).await,
70            BrowserAction::Evaluate { expression } => Self::evaluate(session, expression).await,
71            BrowserAction::GetCookies => Self::get_cookies(session).await,
72            BrowserAction::ClearCookies => Self::clear_cookies(session).await,
73            BrowserAction::ReadPage => Self::read_page(session).await,
74            BrowserAction::GoBack => Self::go_back(session).await,
75            BrowserAction::GoForward => Self::go_forward(session).await,
76            BrowserAction::Reload => Self::reload(session).await,
77        }
78    }
79
80    const BLOCKED_URL_SCHEMES: &[&str] = &[
81        "file://",
82        "javascript:",
83        "data:",
84        "chrome://",
85        "chrome-extension://",
86        "about:",
87        "blob:",
88    ];
89
90    pub fn is_url_scheme_blocked(url: &str) -> bool {
91        let lower = url.trim().to_lowercase();
92        Self::BLOCKED_URL_SCHEMES
93            .iter()
94            .any(|scheme| lower.starts_with(scheme))
95    }
96
97    async fn navigate(session: &CdpSession, url: &str) -> ActionResult {
98        if Self::is_url_scheme_blocked(url) {
99            return ActionResult::err(
100                "navigate",
101                format!("URL scheme is blocked for security: {url}"),
102            );
103        }
104
105        match session
106            .send_command("Page.navigate", json!({ "url": url }))
107            .await
108        {
109            Ok(result) => {
110                if let Some(err_text) = result.get("errorText").and_then(|e| e.as_str()) {
111                    return ActionResult::err("navigate", format!("navigation error: {err_text}"));
112                }
113                let frame_id = result
114                    .get("frameId")
115                    .and_then(|f| f.as_str())
116                    .unwrap_or("")
117                    .to_string();
118                ActionResult::ok("navigate", json!({ "url": url, "frameId": frame_id }))
119            }
120            Err(e) => ActionResult::err("navigate", e.to_string()),
121        }
122    }
123
124    /// Maximum allowed length for CSS selectors to prevent abuse.
125    const MAX_SELECTOR_LENGTH: usize = 500;
126
127    /// Validates a CSS selector to prevent injection attacks.
128    /// Rejects selectors that are too long or contain characters that could
129    /// escape the CSS context (curly braces enable CSS rule injection).
130    fn validate_selector(selector: &str) -> Result<(), String> {
131        if selector.len() > Self::MAX_SELECTOR_LENGTH {
132            return Err(format!(
133                "selector too long ({} chars, max {})",
134                selector.len(),
135                Self::MAX_SELECTOR_LENGTH
136            ));
137        }
138        if selector.contains('{') || selector.contains('}') {
139            return Err("selector contains invalid characters".to_string());
140        }
141        Ok(())
142    }
143
144    async fn click(session: &CdpSession, selector: &str) -> ActionResult {
145        if let Err(e) = Self::validate_selector(selector) {
146            return ActionResult::err("click", e);
147        }
148        let selector_json =
149            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
150
151        let js = format!(
152            r#"(() => {{
153                const el = document.querySelector({sel});
154                if (!el) return JSON.stringify({{error: "element not found"}});
155                const rect = el.getBoundingClientRect();
156                return JSON.stringify({{
157                    x: rect.x + rect.width / 2,
158                    y: rect.y + rect.height / 2
159                }});
160            }})()"#,
161            sel = selector_json
162        );
163
164        let eval_result = match session
165            .send_command(
166                "Runtime.evaluate",
167                json!({ "expression": js, "returnByValue": true }),
168            )
169            .await
170        {
171            Ok(r) => r,
172            Err(e) => return ActionResult::err("click", e.to_string()),
173        };
174
175        let value_str = eval_result
176            .pointer("/result/value")
177            .and_then(|v| v.as_str())
178            .unwrap_or("{}");
179
180        let coords: Value = serde_json::from_str(value_str).unwrap_or(json!({}));
181
182        if coords.get("error").is_some() {
183            return ActionResult::err(
184                "click",
185                format!("selector '{}' not found on page", selector),
186            );
187        }
188
189        let x = coords["x"].as_f64().unwrap_or(0.0);
190        let y = coords["y"].as_f64().unwrap_or(0.0);
191
192        for event_type in ["mousePressed", "mouseReleased"] {
193            if let Err(e) = session
194                .send_command(
195                    "Input.dispatchMouseEvent",
196                    json!({
197                        "type": event_type,
198                        "x": x,
199                        "y": y,
200                        "button": "left",
201                        "clickCount": 1,
202                    }),
203                )
204                .await
205            {
206                return ActionResult::err("click", e.to_string());
207            }
208        }
209
210        ActionResult::ok("click", json!({ "selector": selector, "x": x, "y": y }))
211    }
212
213    async fn type_text(session: &CdpSession, selector: &str, text: &str) -> ActionResult {
214        if let Err(e) = Self::validate_selector(selector) {
215            return ActionResult::err("type", e);
216        }
217        let selector_json =
218            serde_json::to_string(selector).unwrap_or_else(|_| format!("\"{}\"", selector));
219
220        let focus_js = format!(
221            r#"(() => {{
222                const el = document.querySelector({sel});
223                if (!el) return "not_found";
224                el.focus();
225                return "ok";
226            }})()"#,
227            sel = selector_json
228        );
229
230        let focus_result = match session
231            .send_command(
232                "Runtime.evaluate",
233                json!({ "expression": focus_js, "returnByValue": true }),
234            )
235            .await
236        {
237            Ok(r) => r,
238            Err(e) => return ActionResult::err("type", e.to_string()),
239        };
240
241        let focus_status = focus_result
242            .pointer("/result/value")
243            .and_then(|v| v.as_str())
244            .unwrap_or("error");
245
246        if focus_status == "not_found" {
247            return ActionResult::err("type", format!("selector '{}' not found on page", selector));
248        }
249
250        match session
251            .send_command("Input.insertText", json!({ "text": text }))
252            .await
253        {
254            Ok(_) => ActionResult::ok(
255                "type",
256                json!({ "selector": selector, "text": text, "length": text.len() }),
257            ),
258            Err(e) => ActionResult::err("type", e.to_string()),
259        }
260    }
261
262    async fn screenshot(session: &CdpSession) -> ActionResult {
263        match session
264            .send_command(
265                "Page.captureScreenshot",
266                json!({ "format": "png", "quality": 80 }),
267            )
268            .await
269        {
270            Ok(result) => {
271                let data = result
272                    .get("data")
273                    .and_then(|d| d.as_str())
274                    .unwrap_or("")
275                    .to_string();
276                let byte_len = data.len() * 3 / 4; // approximate decoded size
277                ActionResult::ok(
278                    "screenshot",
279                    json!({
280                        "format": "png",
281                        "data_base64_length": data.len(),
282                        "approximate_bytes": byte_len,
283                        "data": data,
284                    }),
285                )
286            }
287            Err(e) => ActionResult::err("screenshot", e.to_string()),
288        }
289    }
290
291    async fn pdf(session: &CdpSession) -> ActionResult {
292        match session
293            .send_command("Page.printToPDF", json!({ "printBackground": true }))
294            .await
295        {
296            Ok(result) => {
297                let data = result
298                    .get("data")
299                    .and_then(|d| d.as_str())
300                    .unwrap_or("")
301                    .to_string();
302                ActionResult::ok(
303                    "pdf",
304                    json!({
305                        "data_base64_length": data.len(),
306                        "data": data,
307                    }),
308                )
309            }
310            Err(e) => ActionResult::err("pdf", e.to_string()),
311        }
312    }
313
314    // SECURITY: expressions are controlled by the agent, not end users.
315    // The length limit guards against accidental megabyte-sized payloads
316    // from prompt injection or runaway template expansion.
317    async fn evaluate(session: &CdpSession, expression: &str) -> ActionResult {
318        if expression.len() > MAX_EXPRESSION_LENGTH {
319            return ActionResult::err(
320                "evaluate",
321                format!(
322                    "expression too large ({} chars, max {})",
323                    expression.len(),
324                    MAX_EXPRESSION_LENGTH
325                ),
326            );
327        }
328
329        debug!(
330            expression_len = expression.len(),
331            "evaluating JS expression"
332        );
333
334        match session
335            .send_command(
336                "Runtime.evaluate",
337                json!({ "expression": expression, "returnByValue": true }),
338            )
339            .await
340        {
341            Ok(result) => {
342                let value = result.get("result").cloned().unwrap_or(json!(null));
343
344                if let Some(exception) = result.get("exceptionDetails") {
345                    let text = exception
346                        .get("text")
347                        .and_then(|t| t.as_str())
348                        .unwrap_or("JavaScript exception");
349                    return ActionResult::err("evaluate", text.to_string());
350                }
351
352                ActionResult::ok("evaluate", value)
353            }
354            Err(e) => ActionResult::err("evaluate", e.to_string()),
355        }
356    }
357
358    async fn read_page(session: &CdpSession) -> ActionResult {
359        let js = r#"JSON.stringify({
360            url: location.href,
361            title: document.title,
362            text: document.body ? document.body.innerText.substring(0, 50000) : "",
363            html_length: document.documentElement.outerHTML.length
364        })"#;
365
366        match session
367            .send_command(
368                "Runtime.evaluate",
369                json!({ "expression": js, "returnByValue": true }),
370            )
371            .await
372        {
373            Ok(result) => {
374                let raw = result
375                    .pointer("/result/value")
376                    .and_then(|v| v.as_str())
377                    .unwrap_or("{}");
378                let page: Value = serde_json::from_str(raw).unwrap_or(json!({}));
379                ActionResult::ok("read_page", page)
380            }
381            Err(e) => ActionResult::err("read_page", e.to_string()),
382        }
383    }
384
385    async fn get_cookies(session: &CdpSession) -> ActionResult {
386        match session.send_command("Network.getCookies", json!({})).await {
387            Ok(result) => {
388                let cookies = result.get("cookies").cloned().unwrap_or(json!([]));
389                let count = cookies.as_array().map(|a| a.len()).unwrap_or(0);
390                ActionResult::ok("get_cookies", json!({ "cookies": cookies, "count": count }))
391            }
392            Err(e) => ActionResult::err("get_cookies", e.to_string()),
393        }
394    }
395
396    async fn clear_cookies(session: &CdpSession) -> ActionResult {
397        match session
398            .send_command("Network.clearBrowserCookies", json!({}))
399            .await
400        {
401            Ok(_) => ActionResult::ok("clear_cookies", json!({ "cleared": true })),
402            Err(e) => ActionResult::err("clear_cookies", e.to_string()),
403        }
404    }
405
406    async fn go_back(session: &CdpSession) -> ActionResult {
407        let js = r#"(() => { history.back(); return "ok"; })()"#;
408        match session
409            .send_command(
410                "Runtime.evaluate",
411                json!({ "expression": js, "returnByValue": true }),
412            )
413            .await
414        {
415            Ok(_) => ActionResult::ok("go_back", json!({ "navigated": "back" })),
416            Err(e) => ActionResult::err("go_back", e.to_string()),
417        }
418    }
419
420    async fn go_forward(session: &CdpSession) -> ActionResult {
421        let js = r#"(() => { history.forward(); return "ok"; })()"#;
422        match session
423            .send_command(
424                "Runtime.evaluate",
425                json!({ "expression": js, "returnByValue": true }),
426            )
427            .await
428        {
429            Ok(_) => ActionResult::ok("go_forward", json!({ "navigated": "forward" })),
430            Err(e) => ActionResult::err("go_forward", e.to_string()),
431        }
432    }
433
434    async fn reload(session: &CdpSession) -> ActionResult {
435        match session
436            .send_command("Page.reload", json!({ "ignoreCache": false }))
437            .await
438        {
439            Ok(_) => ActionResult::ok("reload", json!({ "reloaded": true })),
440            Err(e) => ActionResult::err("reload", e.to_string()),
441        }
442    }
443}
444
445/// Maps a `BrowserAction` to the name string used in `ActionResult::action`.
446pub fn action_name(action: &BrowserAction) -> &'static str {
447    match action {
448        BrowserAction::Navigate { .. } => "navigate",
449        BrowserAction::Click { .. } => "click",
450        BrowserAction::Type { .. } => "type",
451        BrowserAction::Screenshot => "screenshot",
452        BrowserAction::Pdf => "pdf",
453        BrowserAction::Evaluate { .. } => "evaluate",
454        BrowserAction::GetCookies => "get_cookies",
455        BrowserAction::ClearCookies => "clear_cookies",
456        BrowserAction::ReadPage => "read_page",
457        BrowserAction::GoBack => "go_back",
458        BrowserAction::GoForward => "go_forward",
459        BrowserAction::Reload => "reload",
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn action_navigate_serde() {
469        let action = BrowserAction::Navigate {
470            url: "https://example.com".into(),
471        };
472        let json = serde_json::to_string(&action).unwrap();
473        assert!(json.contains("Navigate"));
474        assert!(json.contains("https://example.com"));
475    }
476
477    #[test]
478    fn action_click_serde() {
479        let action = BrowserAction::Click {
480            selector: "#btn".into(),
481        };
482        let json = serde_json::to_string(&action).unwrap();
483        let back: BrowserAction = serde_json::from_str(&json).unwrap();
484        match back {
485            BrowserAction::Click { selector } => assert_eq!(selector, "#btn"),
486            _ => panic!("wrong variant"),
487        }
488    }
489
490    #[test]
491    fn action_type_serde() {
492        let action = BrowserAction::Type {
493            selector: "#input".into(),
494            text: "hello".into(),
495        };
496        let json = serde_json::to_string(&action).unwrap();
497        let back: BrowserAction = serde_json::from_str(&json).unwrap();
498        match back {
499            BrowserAction::Type { selector, text } => {
500                assert_eq!(selector, "#input");
501                assert_eq!(text, "hello");
502            }
503            _ => panic!("wrong variant"),
504        }
505    }
506
507    #[test]
508    fn action_result_ok() {
509        let result = ActionResult::ok("navigate", json!({"url": "https://test.com"}));
510        assert!(result.success);
511        assert!(result.error.is_none());
512        assert_eq!(result.action, "navigate");
513    }
514
515    #[test]
516    fn action_result_err() {
517        let result = ActionResult::err("screenshot", "browser not running".into());
518        assert!(!result.success);
519        assert!(result.data.is_none());
520        assert_eq!(result.error.as_deref(), Some("browser not running"));
521    }
522
523    #[test]
524    fn all_actions_serialize() {
525        let actions = vec![
526            BrowserAction::Navigate { url: "u".into() },
527            BrowserAction::Click {
528                selector: "s".into(),
529            },
530            BrowserAction::Type {
531                selector: "s".into(),
532                text: "t".into(),
533            },
534            BrowserAction::Screenshot,
535            BrowserAction::Pdf,
536            BrowserAction::Evaluate {
537                expression: "1+1".into(),
538            },
539            BrowserAction::GetCookies,
540            BrowserAction::ClearCookies,
541            BrowserAction::ReadPage,
542            BrowserAction::GoBack,
543            BrowserAction::GoForward,
544            BrowserAction::Reload,
545        ];
546        for action in &actions {
547            let json = serde_json::to_string(action).unwrap();
548            assert!(!json.is_empty());
549        }
550    }
551
552    #[test]
553    fn action_names_match() {
554        assert_eq!(
555            action_name(&BrowserAction::Navigate { url: "x".into() }),
556            "navigate"
557        );
558        assert_eq!(
559            action_name(&BrowserAction::Click {
560                selector: "x".into()
561            }),
562            "click"
563        );
564        assert_eq!(action_name(&BrowserAction::Screenshot), "screenshot");
565        assert_eq!(action_name(&BrowserAction::Pdf), "pdf");
566        assert_eq!(
567            action_name(&BrowserAction::Evaluate {
568                expression: "x".into()
569            }),
570            "evaluate"
571        );
572        assert_eq!(action_name(&BrowserAction::GetCookies), "get_cookies");
573        assert_eq!(action_name(&BrowserAction::ClearCookies), "clear_cookies");
574        assert_eq!(action_name(&BrowserAction::ReadPage), "read_page");
575        assert_eq!(action_name(&BrowserAction::GoBack), "go_back");
576        assert_eq!(action_name(&BrowserAction::GoForward), "go_forward");
577        assert_eq!(action_name(&BrowserAction::Reload), "reload");
578    }
579
580    #[test]
581    fn action_result_json_roundtrip() {
582        let result = ActionResult::ok("evaluate", json!({"value": 42}));
583        let json_str = serde_json::to_string(&result).unwrap();
584        let back: ActionResult = serde_json::from_str(&json_str).unwrap();
585        assert!(back.success);
586        assert_eq!(back.action, "evaluate");
587        assert_eq!(back.data.unwrap()["value"], 42);
588    }
589
590    #[test]
591    fn blocked_url_schemes() {
592        assert!(ActionExecutor::is_url_scheme_blocked("file:///etc/passwd"));
593        assert!(ActionExecutor::is_url_scheme_blocked("javascript:alert(1)"));
594        assert!(ActionExecutor::is_url_scheme_blocked(
595            "data:text/html,<h1>hi</h1>"
596        ));
597        assert!(ActionExecutor::is_url_scheme_blocked("chrome://settings"));
598        assert!(ActionExecutor::is_url_scheme_blocked(
599            "chrome-extension://abc/popup.html"
600        ));
601        assert!(ActionExecutor::is_url_scheme_blocked("about:blank"));
602        assert!(ActionExecutor::is_url_scheme_blocked(
603            "blob:http://example.com/uuid"
604        ));
605        assert!(ActionExecutor::is_url_scheme_blocked(
606            "  FILE:///etc/passwd"
607        ));
608    }
609
610    #[test]
611    fn allowed_url_schemes() {
612        assert!(!ActionExecutor::is_url_scheme_blocked(
613            "https://example.com"
614        ));
615        assert!(!ActionExecutor::is_url_scheme_blocked(
616            "http://localhost:3000"
617        ));
618        assert!(!ActionExecutor::is_url_scheme_blocked(
619            "https://google.com/search?q=test"
620        ));
621    }
622
623    #[test]
624    fn action_result_serde_roundtrip_ok() {
625        let result = ActionResult::ok("test", json!({"key": "value"}));
626        let json_str = serde_json::to_string(&result).unwrap();
627        let back: ActionResult = serde_json::from_str(&json_str).unwrap();
628        assert!(back.success);
629        assert_eq!(back.action, "test");
630        assert_eq!(back.data.unwrap()["key"], "value");
631        assert!(back.error.is_none());
632    }
633
634    #[test]
635    fn action_result_serde_roundtrip_err() {
636        let result = ActionResult::err("fail_action", "something broke".into());
637        let json_str = serde_json::to_string(&result).unwrap();
638        let back: ActionResult = serde_json::from_str(&json_str).unwrap();
639        assert!(!back.success);
640        assert_eq!(back.action, "fail_action");
641        assert!(back.data.is_none());
642        assert_eq!(back.error.as_deref(), Some("something broke"));
643    }
644
645    #[test]
646    fn all_action_names_exhaustive() {
647        // Verify action_name covers every variant
648        let variants: Vec<(BrowserAction, &str)> = vec![
649            (BrowserAction::Navigate { url: "x".into() }, "navigate"),
650            (
651                BrowserAction::Click {
652                    selector: "x".into(),
653                },
654                "click",
655            ),
656            (
657                BrowserAction::Type {
658                    selector: "x".into(),
659                    text: "y".into(),
660                },
661                "type",
662            ),
663            (BrowserAction::Screenshot, "screenshot"),
664            (BrowserAction::Pdf, "pdf"),
665            (
666                BrowserAction::Evaluate {
667                    expression: "x".into(),
668                },
669                "evaluate",
670            ),
671            (BrowserAction::GetCookies, "get_cookies"),
672            (BrowserAction::ClearCookies, "clear_cookies"),
673            (BrowserAction::ReadPage, "read_page"),
674            (BrowserAction::GoBack, "go_back"),
675            (BrowserAction::GoForward, "go_forward"),
676            (BrowserAction::Reload, "reload"),
677        ];
678        for (action, expected_name) in &variants {
679            assert_eq!(action_name(action), *expected_name);
680        }
681    }
682
683    #[test]
684    fn action_deserialize_all_variants() {
685        let cases = vec![
686            r##"{"action":"Navigate","url":"https://example.com"}"##,
687            r##"{"action":"Click","selector":"#btn"}"##,
688            r##"{"action":"Type","selector":"input","text":"hi"}"##,
689            r##"{"action":"Screenshot"}"##,
690            r##"{"action":"Pdf"}"##,
691            r##"{"action":"Evaluate","expression":"1+1"}"##,
692            r##"{"action":"GetCookies"}"##,
693            r##"{"action":"ClearCookies"}"##,
694            r##"{"action":"ReadPage"}"##,
695            r##"{"action":"GoBack"}"##,
696            r##"{"action":"GoForward"}"##,
697            r##"{"action":"Reload"}"##,
698        ];
699        for json_str in &cases {
700            let action: BrowserAction = serde_json::from_str(json_str).unwrap();
701            let reserialized = serde_json::to_string(&action).unwrap();
702            assert!(!reserialized.is_empty());
703        }
704    }
705
706    // ─── Mock CDP session tests ─────────────────────────────────────────
707    // These tests create a real WebSocket server that echoes appropriate
708    // CDP responses, then connect a CdpSession to it and run
709    // ActionExecutor methods.
710
711    use futures_util::{SinkExt, StreamExt};
712    use std::time::Duration;
713    use tokio::net::TcpListener;
714    use tokio_tungstenite::tungstenite::Message;
715
716    /// Spin up a mock CDP server that responds to commands with the given handler.
717    async fn mock_cdp_session<F>(handler: F) -> CdpSession
718    where
719        F: Fn(Value) -> Value + Send + 'static,
720    {
721        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
722        let port = listener.local_addr().unwrap().port();
723        let url = format!("ws://127.0.0.1:{port}");
724
725        tokio::spawn(async move {
726            if let Ok((stream, _addr)) = listener.accept().await {
727                let ws = tokio_tungstenite::accept_async(stream).await.unwrap();
728                let (mut sink, mut source) = ws.split();
729                while let Some(Ok(msg)) = source.next().await {
730                    if let Message::Text(ref t) = msg
731                        && let Ok(req) = serde_json::from_str::<Value>(t)
732                    {
733                        let resp = handler(req);
734                        let _ = sink
735                            .send(Message::Text(serde_json::to_string(&resp).unwrap()))
736                            .await;
737                    }
738                }
739            }
740        });
741
742        tokio::time::sleep(Duration::from_millis(50)).await;
743        CdpSession::connect(&url).await.unwrap()
744    }
745
746    #[tokio::test]
747    async fn execute_navigate_success() {
748        let session = mock_cdp_session(|req| {
749            let id = req["id"].as_u64().unwrap();
750            json!({"id": id, "result": {"frameId": "frame1"}})
751        })
752        .await;
753
754        let action = BrowserAction::Navigate {
755            url: "https://example.com".into(),
756        };
757        let result = ActionExecutor::execute(&session, &action).await;
758        assert!(
759            result.success,
760            "navigate should succeed: {:?}",
761            result.error
762        );
763        assert_eq!(result.action, "navigate");
764        let data = result.data.unwrap();
765        assert_eq!(data["url"], "https://example.com");
766        assert_eq!(data["frameId"], "frame1");
767    }
768
769    #[tokio::test]
770    async fn execute_navigate_blocked_scheme() {
771        // Don't even need a real session for this, but let's test via execute()
772        let session = mock_cdp_session(|req| {
773            let id = req["id"].as_u64().unwrap();
774            json!({"id": id, "result": {}})
775        })
776        .await;
777
778        let action = BrowserAction::Navigate {
779            url: "file:///etc/passwd".into(),
780        };
781        let result = ActionExecutor::execute(&session, &action).await;
782        assert!(!result.success);
783        assert!(result.error.as_deref().unwrap().contains("blocked"));
784    }
785
786    #[tokio::test]
787    async fn execute_navigate_with_error_text() {
788        let session = mock_cdp_session(|req| {
789            let id = req["id"].as_u64().unwrap();
790            json!({"id": id, "result": {"errorText": "net::ERR_NAME_NOT_RESOLVED"}})
791        })
792        .await;
793
794        let action = BrowserAction::Navigate {
795            url: "https://nonexistent.invalid".into(),
796        };
797        let result = ActionExecutor::execute(&session, &action).await;
798        assert!(!result.success);
799        assert!(
800            result
801                .error
802                .as_deref()
803                .unwrap()
804                .contains("ERR_NAME_NOT_RESOLVED")
805        );
806    }
807
808    #[tokio::test]
809    async fn execute_navigate_cdp_error() {
810        let session = mock_cdp_session(|req| {
811            let id = req["id"].as_u64().unwrap();
812            json!({"id": id, "error": {"code": -32000, "message": "Navigation failed"}})
813        })
814        .await;
815
816        let action = BrowserAction::Navigate {
817            url: "https://example.com".into(),
818        };
819        let result = ActionExecutor::execute(&session, &action).await;
820        assert!(!result.success);
821    }
822
823    #[tokio::test]
824    async fn execute_click_element_found() {
825        let session = mock_cdp_session(|req| {
826            let id = req["id"].as_u64().unwrap();
827            let method = req["method"].as_str().unwrap_or("");
828            match method {
829                "Runtime.evaluate" => {
830                    // Return coordinates
831                    json!({"id": id, "result": {"result": {"value": r#"{"x":100,"y":200}"#}}})
832                }
833                "Input.dispatchMouseEvent" => {
834                    json!({"id": id, "result": {}})
835                }
836                _ => json!({"id": id, "result": {}}),
837            }
838        })
839        .await;
840
841        let action = BrowserAction::Click {
842            selector: "#btn".into(),
843        };
844        let result = ActionExecutor::execute(&session, &action).await;
845        assert!(result.success, "click should succeed: {:?}", result.error);
846        let data = result.data.unwrap();
847        assert_eq!(data["x"], 100.0);
848        assert_eq!(data["y"], 200.0);
849    }
850
851    #[tokio::test]
852    async fn execute_click_element_not_found() {
853        let session = mock_cdp_session(|req| {
854            let id = req["id"].as_u64().unwrap();
855            json!({"id": id, "result": {"result": {"value": r#"{"error":"element not found"}"#}}})
856        })
857        .await;
858
859        let action = BrowserAction::Click {
860            selector: "#nonexistent".into(),
861        };
862        let result = ActionExecutor::execute(&session, &action).await;
863        assert!(!result.success);
864        assert!(result.error.as_deref().unwrap().contains("not found"));
865    }
866
867    #[tokio::test]
868    async fn execute_type_success() {
869        let session = mock_cdp_session(|req| {
870            let id = req["id"].as_u64().unwrap();
871            let method = req["method"].as_str().unwrap_or("");
872            match method {
873                "Runtime.evaluate" => {
874                    json!({"id": id, "result": {"result": {"value": "ok"}}})
875                }
876                "Input.insertText" => {
877                    json!({"id": id, "result": {}})
878                }
879                _ => json!({"id": id, "result": {}}),
880            }
881        })
882        .await;
883
884        let action = BrowserAction::Type {
885            selector: "input".into(),
886            text: "hello world".into(),
887        };
888        let result = ActionExecutor::execute(&session, &action).await;
889        assert!(result.success, "type should succeed: {:?}", result.error);
890        let data = result.data.unwrap();
891        assert_eq!(data["text"], "hello world");
892        assert_eq!(data["length"], 11);
893    }
894
895    #[tokio::test]
896    async fn execute_type_element_not_found() {
897        let session = mock_cdp_session(|req| {
898            let id = req["id"].as_u64().unwrap();
899            json!({"id": id, "result": {"result": {"value": "not_found"}}})
900        })
901        .await;
902
903        let action = BrowserAction::Type {
904            selector: "#missing".into(),
905            text: "test".into(),
906        };
907        let result = ActionExecutor::execute(&session, &action).await;
908        assert!(!result.success);
909        assert!(result.error.as_deref().unwrap().contains("not found"));
910    }
911
912    #[tokio::test]
913    async fn execute_screenshot_success() {
914        let session = mock_cdp_session(|req| {
915            let id = req["id"].as_u64().unwrap();
916            json!({"id": id, "result": {"data": "iVBORw0KGgo="}})
917        })
918        .await;
919
920        let result = ActionExecutor::execute(&session, &BrowserAction::Screenshot).await;
921        assert!(
922            result.success,
923            "screenshot should succeed: {:?}",
924            result.error
925        );
926        let data = result.data.unwrap();
927        assert_eq!(data["format"], "png");
928        assert!(data["data_base64_length"].as_u64().unwrap() > 0);
929    }
930
931    #[tokio::test]
932    async fn execute_screenshot_no_data() {
933        let session = mock_cdp_session(|req| {
934            let id = req["id"].as_u64().unwrap();
935            json!({"id": id, "result": {}})
936        })
937        .await;
938
939        let result = ActionExecutor::execute(&session, &BrowserAction::Screenshot).await;
940        assert!(result.success);
941        let data = result.data.unwrap();
942        assert_eq!(data["data"], "");
943    }
944
945    #[tokio::test]
946    async fn execute_pdf_success() {
947        let session = mock_cdp_session(|req| {
948            let id = req["id"].as_u64().unwrap();
949            json!({"id": id, "result": {"data": "JVBERi0xLjQ="}})
950        })
951        .await;
952
953        let result = ActionExecutor::execute(&session, &BrowserAction::Pdf).await;
954        assert!(result.success, "pdf should succeed: {:?}", result.error);
955        let data = result.data.unwrap();
956        assert!(data["data_base64_length"].as_u64().unwrap() > 0);
957    }
958
959    #[tokio::test]
960    async fn execute_evaluate_success() {
961        let session = mock_cdp_session(|req| {
962            let id = req["id"].as_u64().unwrap();
963            json!({"id": id, "result": {"result": {"type": "number", "value": 42}}})
964        })
965        .await;
966
967        let action = BrowserAction::Evaluate {
968            expression: "21 * 2".into(),
969        };
970        let result = ActionExecutor::execute(&session, &action).await;
971        assert!(
972            result.success,
973            "evaluate should succeed: {:?}",
974            result.error
975        );
976        let data = result.data.unwrap();
977        assert_eq!(data["value"], 42);
978    }
979
980    #[tokio::test]
981    async fn execute_evaluate_expression_too_large() {
982        let session = mock_cdp_session(|req| {
983            let id = req["id"].as_u64().unwrap();
984            json!({"id": id, "result": {}})
985        })
986        .await;
987
988        let big_expr = "x".repeat(MAX_EXPRESSION_LENGTH + 1);
989        let action = BrowserAction::Evaluate {
990            expression: big_expr,
991        };
992        let result = ActionExecutor::execute(&session, &action).await;
993        assert!(!result.success);
994        assert!(result.error.as_deref().unwrap().contains("too large"));
995    }
996
997    #[tokio::test]
998    async fn execute_evaluate_js_exception() {
999        let session = mock_cdp_session(|req| {
1000            let id = req["id"].as_u64().unwrap();
1001            json!({
1002                "id": id,
1003                "result": {
1004                    "result": {"type": "object"},
1005                    "exceptionDetails": {
1006                        "text": "ReferenceError: foo is not defined"
1007                    }
1008                }
1009            })
1010        })
1011        .await;
1012
1013        let action = BrowserAction::Evaluate {
1014            expression: "foo()".into(),
1015        };
1016        let result = ActionExecutor::execute(&session, &action).await;
1017        assert!(!result.success);
1018        assert!(result.error.as_deref().unwrap().contains("ReferenceError"));
1019    }
1020
1021    #[tokio::test]
1022    async fn execute_evaluate_exception_no_text() {
1023        let session = mock_cdp_session(|req| {
1024            let id = req["id"].as_u64().unwrap();
1025            json!({
1026                "id": id,
1027                "result": {
1028                    "result": {"type": "object"},
1029                    "exceptionDetails": {}
1030                }
1031            })
1032        })
1033        .await;
1034
1035        let action = BrowserAction::Evaluate {
1036            expression: "bad()".into(),
1037        };
1038        let result = ActionExecutor::execute(&session, &action).await;
1039        assert!(!result.success);
1040        assert!(
1041            result
1042                .error
1043                .as_deref()
1044                .unwrap()
1045                .contains("JavaScript exception")
1046        );
1047    }
1048
1049    #[tokio::test]
1050    async fn execute_read_page_success() {
1051        let session = mock_cdp_session(|req| {
1052            let id = req["id"].as_u64().unwrap();
1053            let page_json = serde_json::to_string(&json!({
1054                "url": "https://example.com",
1055                "title": "Example",
1056                "text": "Hello World",
1057                "html_length": 1234
1058            }))
1059            .unwrap();
1060            json!({"id": id, "result": {"result": {"value": page_json}}})
1061        })
1062        .await;
1063
1064        let result = ActionExecutor::execute(&session, &BrowserAction::ReadPage).await;
1065        assert!(
1066            result.success,
1067            "read_page should succeed: {:?}",
1068            result.error
1069        );
1070        let data = result.data.unwrap();
1071        assert_eq!(data["url"], "https://example.com");
1072        assert_eq!(data["title"], "Example");
1073    }
1074
1075    #[tokio::test]
1076    async fn execute_get_cookies_success() {
1077        let session = mock_cdp_session(|req| {
1078            let id = req["id"].as_u64().unwrap();
1079            json!({"id": id, "result": {"cookies": [{"name": "sid", "value": "abc"}]}})
1080        })
1081        .await;
1082
1083        let result = ActionExecutor::execute(&session, &BrowserAction::GetCookies).await;
1084        assert!(
1085            result.success,
1086            "get_cookies should succeed: {:?}",
1087            result.error
1088        );
1089        let data = result.data.unwrap();
1090        assert_eq!(data["count"], 1);
1091    }
1092
1093    #[tokio::test]
1094    async fn execute_clear_cookies_success() {
1095        let session = mock_cdp_session(|req| {
1096            let id = req["id"].as_u64().unwrap();
1097            json!({"id": id, "result": {}})
1098        })
1099        .await;
1100
1101        let result = ActionExecutor::execute(&session, &BrowserAction::ClearCookies).await;
1102        assert!(
1103            result.success,
1104            "clear_cookies should succeed: {:?}",
1105            result.error
1106        );
1107        let data = result.data.unwrap();
1108        assert_eq!(data["cleared"], true);
1109    }
1110
1111    #[tokio::test]
1112    async fn execute_go_back_success() {
1113        let session = mock_cdp_session(|req| {
1114            let id = req["id"].as_u64().unwrap();
1115            json!({"id": id, "result": {"result": {"value": "ok"}}})
1116        })
1117        .await;
1118
1119        let result = ActionExecutor::execute(&session, &BrowserAction::GoBack).await;
1120        assert!(result.success, "go_back should succeed: {:?}", result.error);
1121        assert_eq!(result.data.unwrap()["navigated"], "back");
1122    }
1123
1124    #[tokio::test]
1125    async fn execute_go_forward_success() {
1126        let session = mock_cdp_session(|req| {
1127            let id = req["id"].as_u64().unwrap();
1128            json!({"id": id, "result": {"result": {"value": "ok"}}})
1129        })
1130        .await;
1131
1132        let result = ActionExecutor::execute(&session, &BrowserAction::GoForward).await;
1133        assert!(
1134            result.success,
1135            "go_forward should succeed: {:?}",
1136            result.error
1137        );
1138        assert_eq!(result.data.unwrap()["navigated"], "forward");
1139    }
1140
1141    #[tokio::test]
1142    async fn execute_reload_success() {
1143        let session = mock_cdp_session(|req| {
1144            let id = req["id"].as_u64().unwrap();
1145            json!({"id": id, "result": {}})
1146        })
1147        .await;
1148
1149        let result = ActionExecutor::execute(&session, &BrowserAction::Reload).await;
1150        assert!(result.success, "reload should succeed: {:?}", result.error);
1151        assert_eq!(result.data.unwrap()["reloaded"], true);
1152    }
1153
1154    #[tokio::test]
1155    async fn execute_navigate_cdp_send_error() {
1156        // Test when the CDP session returns an error (not a CDP protocol error)
1157        let session = mock_cdp_session(|req| {
1158            let id = req["id"].as_u64().unwrap();
1159            json!({"id": id, "error": {"code": -32601, "message": "Method not found"}})
1160        })
1161        .await;
1162
1163        let action = BrowserAction::Navigate {
1164            url: "https://example.com".into(),
1165        };
1166        let result = ActionExecutor::execute(&session, &action).await;
1167        assert!(!result.success);
1168    }
1169
1170    #[tokio::test]
1171    async fn execute_click_cdp_error() {
1172        let session = mock_cdp_session(|req| {
1173            let id = req["id"].as_u64().unwrap();
1174            json!({"id": id, "error": {"code": -32000, "message": "Target closed"}})
1175        })
1176        .await;
1177
1178        let action = BrowserAction::Click {
1179            selector: "#btn".into(),
1180        };
1181        let result = ActionExecutor::execute(&session, &action).await;
1182        assert!(!result.success);
1183    }
1184
1185    #[tokio::test]
1186    async fn execute_type_cdp_error() {
1187        let session = mock_cdp_session(|req| {
1188            let id = req["id"].as_u64().unwrap();
1189            json!({"id": id, "error": {"code": -32000, "message": "Target closed"}})
1190        })
1191        .await;
1192
1193        let action = BrowserAction::Type {
1194            selector: "input".into(),
1195            text: "hello".into(),
1196        };
1197        let result = ActionExecutor::execute(&session, &action).await;
1198        assert!(!result.success);
1199    }
1200
1201    #[tokio::test]
1202    async fn execute_screenshot_cdp_error() {
1203        let session = mock_cdp_session(|req| {
1204            let id = req["id"].as_u64().unwrap();
1205            json!({"id": id, "error": {"code": -32000, "message": "Target closed"}})
1206        })
1207        .await;
1208
1209        let result = ActionExecutor::execute(&session, &BrowserAction::Screenshot).await;
1210        assert!(!result.success);
1211    }
1212
1213    #[tokio::test]
1214    async fn execute_pdf_cdp_error() {
1215        let session = mock_cdp_session(|req| {
1216            let id = req["id"].as_u64().unwrap();
1217            json!({"id": id, "error": {"code": -32000, "message": "Printing failed"}})
1218        })
1219        .await;
1220
1221        let result = ActionExecutor::execute(&session, &BrowserAction::Pdf).await;
1222        assert!(!result.success);
1223    }
1224
1225    #[tokio::test]
1226    async fn execute_evaluate_cdp_error() {
1227        let session = mock_cdp_session(|req| {
1228            let id = req["id"].as_u64().unwrap();
1229            json!({"id": id, "error": {"code": -32000, "message": "Runtime error"}})
1230        })
1231        .await;
1232
1233        let action = BrowserAction::Evaluate {
1234            expression: "1+1".into(),
1235        };
1236        let result = ActionExecutor::execute(&session, &action).await;
1237        assert!(!result.success);
1238    }
1239
1240    #[tokio::test]
1241    async fn execute_read_page_cdp_error() {
1242        let session = mock_cdp_session(|req| {
1243            let id = req["id"].as_u64().unwrap();
1244            json!({"id": id, "error": {"code": -32000, "message": "Eval failed"}})
1245        })
1246        .await;
1247
1248        let result = ActionExecutor::execute(&session, &BrowserAction::ReadPage).await;
1249        assert!(!result.success);
1250    }
1251
1252    #[tokio::test]
1253    async fn execute_get_cookies_cdp_error() {
1254        let session = mock_cdp_session(|req| {
1255            let id = req["id"].as_u64().unwrap();
1256            json!({"id": id, "error": {"code": -32000, "message": "Network error"}})
1257        })
1258        .await;
1259
1260        let result = ActionExecutor::execute(&session, &BrowserAction::GetCookies).await;
1261        assert!(!result.success);
1262    }
1263
1264    #[tokio::test]
1265    async fn execute_clear_cookies_cdp_error() {
1266        let session = mock_cdp_session(|req| {
1267            let id = req["id"].as_u64().unwrap();
1268            json!({"id": id, "error": {"code": -32000, "message": "Clear failed"}})
1269        })
1270        .await;
1271
1272        let result = ActionExecutor::execute(&session, &BrowserAction::ClearCookies).await;
1273        assert!(!result.success);
1274    }
1275
1276    #[tokio::test]
1277    async fn execute_go_back_cdp_error() {
1278        let session = mock_cdp_session(|req| {
1279            let id = req["id"].as_u64().unwrap();
1280            json!({"id": id, "error": {"code": -32000, "message": "Navigation failed"}})
1281        })
1282        .await;
1283
1284        let result = ActionExecutor::execute(&session, &BrowserAction::GoBack).await;
1285        assert!(!result.success);
1286    }
1287
1288    #[tokio::test]
1289    async fn execute_go_forward_cdp_error() {
1290        let session = mock_cdp_session(|req| {
1291            let id = req["id"].as_u64().unwrap();
1292            json!({"id": id, "error": {"code": -32000, "message": "Navigation failed"}})
1293        })
1294        .await;
1295
1296        let result = ActionExecutor::execute(&session, &BrowserAction::GoForward).await;
1297        assert!(!result.success);
1298    }
1299
1300    #[tokio::test]
1301    async fn execute_reload_cdp_error() {
1302        let session = mock_cdp_session(|req| {
1303            let id = req["id"].as_u64().unwrap();
1304            json!({"id": id, "error": {"code": -32000, "message": "Reload failed"}})
1305        })
1306        .await;
1307
1308        let result = ActionExecutor::execute(&session, &BrowserAction::Reload).await;
1309        assert!(!result.success);
1310    }
1311
1312    #[tokio::test]
1313    async fn execute_click_mouse_event_error() {
1314        // First eval succeeds with coords, but mouse dispatch fails
1315        use std::sync::Arc;
1316        use std::sync::atomic::{AtomicU32, Ordering as AtomOrd};
1317
1318        let call_count = Arc::new(AtomicU32::new(0));
1319        let call_count_clone = call_count.clone();
1320
1321        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1322        let port = listener.local_addr().unwrap().port();
1323        let url = format!("ws://127.0.0.1:{port}");
1324
1325        tokio::spawn(async move {
1326            if let Ok((stream, _addr)) = listener.accept().await {
1327                let ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1328                let (mut sink, mut source) = ws.split();
1329                while let Some(Ok(msg)) = source.next().await {
1330                    if let Message::Text(ref t) = msg
1331                        && let Ok(req) = serde_json::from_str::<Value>(t)
1332                    {
1333                        let id = req["id"].as_u64().unwrap();
1334                        let method = req["method"].as_str().unwrap_or("");
1335                        let _n = call_count_clone.fetch_add(1, AtomOrd::SeqCst);
1336
1337                        let resp = match method {
1338                            "Runtime.evaluate" => {
1339                                json!({"id": id, "result": {"result": {"value": r#"{"x":50,"y":50}"#}}})
1340                            }
1341                            "Input.dispatchMouseEvent" => {
1342                                json!({"id": id, "error": {"code": -32000, "message": "Input error"}})
1343                            }
1344                            _ => json!({"id": id, "result": {}}),
1345                        };
1346                        let _ = sink
1347                            .send(Message::Text(serde_json::to_string(&resp).unwrap()))
1348                            .await;
1349                    }
1350                }
1351            }
1352        });
1353
1354        tokio::time::sleep(Duration::from_millis(50)).await;
1355        let session = CdpSession::connect(&url).await.unwrap();
1356
1357        let action = BrowserAction::Click {
1358            selector: "#btn".into(),
1359        };
1360        let result = ActionExecutor::execute(&session, &action).await;
1361        assert!(!result.success);
1362    }
1363
1364    #[tokio::test]
1365    async fn execute_type_insert_text_error() {
1366        // Focus succeeds but insert text fails
1367        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1368        let port = listener.local_addr().unwrap().port();
1369        let url = format!("ws://127.0.0.1:{port}");
1370
1371        tokio::spawn(async move {
1372            if let Ok((stream, _addr)) = listener.accept().await {
1373                let ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1374                let (mut sink, mut source) = ws.split();
1375                while let Some(Ok(msg)) = source.next().await {
1376                    if let Message::Text(ref t) = msg
1377                        && let Ok(req) = serde_json::from_str::<Value>(t)
1378                    {
1379                        let id = req["id"].as_u64().unwrap();
1380                        let method = req["method"].as_str().unwrap_or("");
1381                        let resp = match method {
1382                            "Runtime.evaluate" => {
1383                                json!({"id": id, "result": {"result": {"value": "ok"}}})
1384                            }
1385                            "Input.insertText" => {
1386                                json!({"id": id, "error": {"code": -32000, "message": "Insert failed"}})
1387                            }
1388                            _ => json!({"id": id, "result": {}}),
1389                        };
1390                        let _ = sink
1391                            .send(Message::Text(serde_json::to_string(&resp).unwrap()))
1392                            .await;
1393                    }
1394                }
1395            }
1396        });
1397
1398        tokio::time::sleep(Duration::from_millis(50)).await;
1399        let session = CdpSession::connect(&url).await.unwrap();
1400
1401        let action = BrowserAction::Type {
1402            selector: "input".into(),
1403            text: "hello".into(),
1404        };
1405        let result = ActionExecutor::execute(&session, &action).await;
1406        assert!(!result.success);
1407    }
1408
1409    #[tokio::test]
1410    async fn execute_read_page_invalid_json() {
1411        // Server returns something that is not valid JSON in the value
1412        let session = mock_cdp_session(|req| {
1413            let id = req["id"].as_u64().unwrap();
1414            json!({"id": id, "result": {"result": {"value": "not valid json"}}})
1415        })
1416        .await;
1417
1418        let result = ActionExecutor::execute(&session, &BrowserAction::ReadPage).await;
1419        // Should still succeed with fallback to empty object
1420        assert!(result.success);
1421    }
1422
1423    #[tokio::test]
1424    async fn execute_get_cookies_empty() {
1425        let session = mock_cdp_session(|req| {
1426            let id = req["id"].as_u64().unwrap();
1427            json!({"id": id, "result": {}})
1428        })
1429        .await;
1430
1431        let result = ActionExecutor::execute(&session, &BrowserAction::GetCookies).await;
1432        assert!(result.success);
1433        let data = result.data.unwrap();
1434        assert_eq!(data["count"], 0);
1435    }
1436
1437    #[tokio::test]
1438    async fn execute_navigate_no_frame_id() {
1439        let session = mock_cdp_session(|req| {
1440            let id = req["id"].as_u64().unwrap();
1441            json!({"id": id, "result": {}})
1442        })
1443        .await;
1444
1445        let action = BrowserAction::Navigate {
1446            url: "https://example.com".into(),
1447        };
1448        let result = ActionExecutor::execute(&session, &action).await;
1449        assert!(result.success);
1450        let data = result.data.unwrap();
1451        assert_eq!(data["frameId"], "");
1452    }
1453
1454    #[tokio::test]
1455    async fn execute_click_no_coords_in_response() {
1456        // Element found but coords are missing from response
1457        let session = mock_cdp_session(|req| {
1458            let id = req["id"].as_u64().unwrap();
1459            let method = req["method"].as_str().unwrap_or("");
1460            match method {
1461                "Runtime.evaluate" => {
1462                    json!({"id": id, "result": {"result": {"value": "{}"}}})
1463                }
1464                "Input.dispatchMouseEvent" => {
1465                    json!({"id": id, "result": {}})
1466                }
1467                _ => json!({"id": id, "result": {}}),
1468            }
1469        })
1470        .await;
1471
1472        let action = BrowserAction::Click {
1473            selector: "#btn".into(),
1474        };
1475        let result = ActionExecutor::execute(&session, &action).await;
1476        // Should succeed (defaults to 0,0 coords)
1477        assert!(result.success);
1478    }
1479}