1use serde::{Deserialize, Serialize};
2use serde_json::{Value, json};
3use tracing::debug;
4
5use crate::session::CdpSession;
6
7const 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
57pub 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 const MAX_SELECTOR_LENGTH: usize = 500;
126
127 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; 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 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
445pub 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 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 use futures_util::{SinkExt, StreamExt};
712 use std::time::Duration;
713 use tokio::net::TcpListener;
714 use tokio_tungstenite::tungstenite::Message;
715
716 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 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 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 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 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 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 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 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 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 assert!(result.success);
1478 }
1479}