1use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13use std::time::Instant;
14
15use async_trait::async_trait;
16use chrono::Utc;
17use dashmap::DashMap;
18use serde::{Deserialize, Serialize};
19use tokio::process::Child;
20use tokio::sync::Mutex;
21use tracing::{debug, info, warn};
22
23
24use crate::browser::{
25 BrowserAction, BrowserConfig, BrowserDriver, BrowserResult, BrowserSession, BrowserState,
26};
27use crate::{PunchError, PunchResult};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CdpConfig {
39 pub chrome_path: Option<String>,
41 pub debug_port: u16,
43 pub headless: bool,
45 pub user_data_dir: Option<String>,
47 pub extra_args: Vec<String>,
49 pub connect_timeout_secs: u64,
51 pub disable_gpu: bool,
53 pub no_sandbox: bool,
55}
56
57impl Default for CdpConfig {
58 fn default() -> Self {
59 Self {
60 chrome_path: None,
61 debug_port: 9222,
62 headless: true,
63 user_data_dir: None,
64 extra_args: Vec::new(),
65 connect_timeout_secs: 10,
66 disable_gpu: true,
67 no_sandbox: true,
68 }
69 }
70}
71
72impl From<&BrowserConfig> for CdpConfig {
73 fn from(config: &BrowserConfig) -> Self {
74 Self {
75 chrome_path: config.chrome_path.clone(),
76 debug_port: config.remote_debugging_port,
77 headless: config.headless,
78 user_data_dir: config.user_data_dir.clone(),
79 ..Default::default()
80 }
81 }
82}
83
84#[derive(Debug, thiserror::Error)]
90pub enum CdpError {
91 #[error("Chrome binary not found; searched: {searched_paths:?}")]
93 ChromeNotFound { searched_paths: Vec<String> },
94
95 #[error("failed to launch Chrome: {reason}")]
97 LaunchFailed { reason: String },
98
99 #[error("failed to connect to CDP on port {port}: {reason}")]
101 ConnectionFailed { port: u16, reason: String },
102
103 #[error("CDP command error (id={command_id}): {message}")]
105 CommandError { command_id: u64, message: String },
106
107 #[error("CDP session not found: {session_id}")]
109 SessionNotFound { session_id: String },
110
111 #[error("unexpected CDP response: {detail}")]
113 UnexpectedResponse { detail: String },
114
115 #[error("CDP operation timed out after {timeout_secs}s")]
117 Timeout { timeout_secs: u64 },
118
119 #[error("CDP HTTP error: {0}")]
121 Http(String),
122}
123
124impl From<CdpError> for PunchError {
125 fn from(err: CdpError) -> Self {
126 PunchError::Tool {
127 tool: "browser_cdp".into(),
128 message: err.to_string(),
129 }
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct CdpSession {
140 pub id: String,
142 pub target_id: String,
144 pub ws_url: String,
146 pub created_at: chrono::DateTime<chrono::Utc>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub struct CdpTargetInfo {
154 #[serde(default)]
156 pub description: String,
157 #[serde(default)]
159 pub devtools_frontend_url: String,
160 pub id: String,
162 #[serde(default)]
164 pub title: String,
165 #[serde(default, rename = "type")]
167 pub target_type: String,
168 #[serde(default)]
170 pub url: String,
171 #[serde(default)]
173 pub web_socket_debugger_url: String,
174}
175
176#[derive(Debug, Clone, Serialize)]
182pub struct CdpCommand {
183 pub id: u64,
185 pub method: String,
187 pub params: serde_json::Value,
189}
190
191impl CdpCommand {
192 pub fn new(id: u64, method: impl Into<String>, params: serde_json::Value) -> Self {
194 Self {
195 id,
196 method: method.into(),
197 params,
198 }
199 }
200
201 pub fn to_json(&self) -> PunchResult<String> {
203 serde_json::to_string(self).map_err(PunchError::from)
204 }
205}
206
207#[derive(Debug, Clone, Deserialize)]
209pub struct CdpResponse {
210 pub id: Option<u64>,
212 pub result: Option<serde_json::Value>,
214 pub error: Option<CdpResponseError>,
216}
217
218#[derive(Debug, Clone, Deserialize)]
220pub struct CdpResponseError {
221 pub code: i64,
223 pub message: String,
225 pub data: Option<serde_json::Value>,
227}
228
229pub fn find_chrome() -> Option<String> {
238 let candidates = chrome_candidate_paths();
239 for path in &candidates {
240 if std::path::Path::new(path).exists() {
241 info!(path = %path, "found Chrome binary");
242 return Some(path.clone());
243 }
244 }
245 debug!(candidates = ?candidates, "Chrome binary not found in known locations");
246 None
247}
248
249pub fn chrome_candidate_paths() -> Vec<String> {
251 let mut paths = Vec::new();
252
253 if cfg!(target_os = "macos") {
255 paths.extend([
256 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string(),
257 "/Applications/Chromium.app/Contents/MacOS/Chromium".to_string(),
258 "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"
259 .to_string(),
260 "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser".to_string(),
261 ]);
262 }
263
264 if cfg!(target_os = "linux") {
266 paths.extend([
267 "/usr/bin/google-chrome".to_string(),
268 "/usr/bin/google-chrome-stable".to_string(),
269 "/usr/bin/chromium".to_string(),
270 "/usr/bin/chromium-browser".to_string(),
271 "/snap/bin/chromium".to_string(),
272 "/usr/bin/brave-browser".to_string(),
273 ]);
274 }
275
276 if cfg!(target_os = "windows") {
278 paths.extend([
279 r"C:\Program Files\Google\Chrome\Application\chrome.exe".to_string(),
280 r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe".to_string(),
281 r"C:\Program Files\Chromium\Application\chrome.exe".to_string(),
282 ]);
283 }
284
285 paths
286}
287
288pub fn build_navigate_command(id: u64, url: &str) -> CdpCommand {
294 CdpCommand::new(
295 id,
296 "Page.navigate",
297 serde_json::json!({ "url": url }),
298 )
299}
300
301pub fn build_screenshot_command(id: u64, full_page: bool) -> CdpCommand {
303 let params = if full_page {
304 serde_json::json!({ "captureBeyondViewport": true })
305 } else {
306 serde_json::json!({})
307 };
308 CdpCommand::new(id, "Page.captureScreenshot", params)
309}
310
311pub fn build_evaluate_command(id: u64, expression: &str) -> CdpCommand {
313 CdpCommand::new(
314 id,
315 "Runtime.evaluate",
316 serde_json::json!({
317 "expression": expression,
318 "returnByValue": true,
319 "awaitPromise": true,
320 }),
321 )
322}
323
324pub fn build_click_command(id: u64, selector: &str) -> CdpCommand {
326 let js = format!(
327 r#"(() => {{
328 const el = document.querySelector({sel});
329 if (!el) throw new Error('Element not found: ' + {sel});
330 el.click();
331 return 'clicked';
332 }})()"#,
333 sel = serde_json::to_string(selector).unwrap_or_default(),
334 );
335 build_evaluate_command(id, &js)
336}
337
338pub fn build_get_content_command(id: u64, selector: Option<&str>) -> CdpCommand {
340 let js = match selector {
341 Some(sel) => {
342 let sel_json = serde_json::to_string(sel).unwrap_or_default();
343 format!(
344 r#"(() => {{
345 const el = document.querySelector({sel});
346 if (!el) throw new Error('Element not found: ' + {sel});
347 return el.textContent;
348 }})()"#,
349 sel = sel_json,
350 )
351 }
352 None => "document.body.innerText".to_string(),
353 };
354 build_evaluate_command(id, &js)
355}
356
357pub fn build_get_html_command(id: u64, selector: Option<&str>) -> CdpCommand {
359 let js = match selector {
360 Some(sel) => {
361 let sel_json = serde_json::to_string(sel).unwrap_or_default();
362 format!(
363 r#"(() => {{
364 const el = document.querySelector({sel});
365 if (!el) throw new Error('Element not found: ' + {sel});
366 return el.outerHTML;
367 }})()"#,
368 sel = sel_json,
369 )
370 }
371 None => "document.documentElement.outerHTML".to_string(),
372 };
373 build_evaluate_command(id, &js)
374}
375
376pub fn build_type_text_command(id: u64, selector: &str, text: &str) -> CdpCommand {
378 let sel_json = serde_json::to_string(selector).unwrap_or_default();
379 let text_json = serde_json::to_string(text).unwrap_or_default();
380 let js = format!(
381 r#"(() => {{
382 const el = document.querySelector({sel});
383 if (!el) throw new Error('Element not found: ' + {sel});
384 el.focus();
385 el.value = {text};
386 el.dispatchEvent(new Event('input', {{ bubbles: true }}));
387 el.dispatchEvent(new Event('change', {{ bubbles: true }}));
388 return 'typed';
389 }})()"#,
390 sel = sel_json,
391 text = text_json,
392 );
393 build_evaluate_command(id, &js)
394}
395
396pub fn build_wait_for_selector_command(id: u64, selector: &str, timeout_ms: u64) -> CdpCommand {
398 let sel_json = serde_json::to_string(selector).unwrap_or_default();
399 let js = format!(
400 r#"new Promise((resolve, reject) => {{
401 const sel = {sel};
402 const timeout = {timeout};
403 const start = Date.now();
404 const check = () => {{
405 const el = document.querySelector(sel);
406 if (el) return resolve('found');
407 if (Date.now() - start > timeout) return reject(new Error('Timeout waiting for: ' + sel));
408 requestAnimationFrame(check);
409 }};
410 check();
411 }})"#,
412 sel = sel_json,
413 timeout = timeout_ms,
414 );
415 build_evaluate_command(id, &js)
416}
417
418pub struct CdpBrowserDriver {
428 client: reqwest::Client,
430 sessions: DashMap<String, CdpSession>,
432 config: CdpConfig,
434 command_counter: AtomicU64,
436 chrome_process: Arc<Mutex<Option<Child>>>,
438 active_port: Arc<Mutex<Option<u16>>>,
440}
441
442impl CdpBrowserDriver {
443 pub fn new(config: CdpConfig) -> Self {
445 Self {
446 client: reqwest::Client::new(),
447 sessions: DashMap::new(),
448 config,
449 command_counter: AtomicU64::new(1),
450 chrome_process: Arc::new(Mutex::new(None)),
451 active_port: Arc::new(Mutex::new(None)),
452 }
453 }
454
455 pub fn with_defaults() -> Self {
457 Self::new(CdpConfig::default())
458 }
459
460 fn next_id(&self) -> u64 {
462 self.command_counter.fetch_add(1, Ordering::Relaxed)
463 }
464
465 async fn debug_port(&self) -> u16 {
467 self.active_port
468 .lock()
469 .await
470 .unwrap_or(self.config.debug_port)
471 }
472
473 async fn base_url(&self) -> String {
475 format!("http://localhost:{}", self.debug_port().await)
476 }
477
478 fn resolve_chrome_path(&self) -> Result<String, CdpError> {
480 if let Some(ref path) = self.config.chrome_path {
481 return Ok(path.clone());
482 }
483 find_chrome().ok_or_else(|| CdpError::ChromeNotFound {
484 searched_paths: chrome_candidate_paths(),
485 })
486 }
487
488 fn build_chrome_args(&self) -> Vec<String> {
490 let mut args = vec![
491 format!("--remote-debugging-port={}", self.config.debug_port),
492 ];
493
494 if self.config.headless {
495 args.push("--headless".to_string());
496 }
497 if self.config.disable_gpu {
498 args.push("--disable-gpu".to_string());
499 }
500 if self.config.no_sandbox {
501 args.push("--no-sandbox".to_string());
502 }
503
504 if let Some(ref dir) = self.config.user_data_dir {
505 args.push(format!("--user-data-dir={}", dir));
506 }
507
508 args.extend(self.config.extra_args.clone());
509
510 args.push("about:blank".to_string());
512
513 args
514 }
515
516 async fn launch_chrome(&self) -> Result<Child, CdpError> {
518 let chrome_path = self.resolve_chrome_path()?;
519 let args = self.build_chrome_args();
520
521 info!(path = %chrome_path, args = ?args, "launching Chrome");
522
523 let child = tokio::process::Command::new(&chrome_path)
524 .args(&args)
525 .stdin(std::process::Stdio::null())
526 .stdout(std::process::Stdio::null())
527 .stderr(std::process::Stdio::piped())
528 .spawn()
529 .map_err(|e| CdpError::LaunchFailed {
530 reason: format!("failed to spawn Chrome at {}: {}", chrome_path, e),
531 })?;
532
533 info!("Chrome process launched successfully");
534 Ok(child)
535 }
536
537 async fn wait_for_cdp_ready(&self) -> Result<(), CdpError> {
539 let base = self.base_url().await;
540 let url = format!("{}/json/version", base);
541 let timeout = self.config.connect_timeout_secs;
542 let start = Instant::now();
543
544 loop {
545 match self.client.get(&url).send().await {
546 Ok(resp) if resp.status().is_success() => {
547 info!(url = %url, "CDP endpoint is ready");
548 return Ok(());
549 }
550 Ok(resp) => {
551 debug!(status = %resp.status(), "CDP endpoint not ready yet");
552 }
553 Err(e) => {
554 debug!(error = %e, "CDP endpoint not reachable yet");
555 }
556 }
557
558 if start.elapsed().as_secs() >= timeout {
559 return Err(CdpError::Timeout {
560 timeout_secs: timeout,
561 });
562 }
563
564 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
565 }
566 }
567
568 async fn create_tab(&self, url: Option<&str>) -> Result<CdpTargetInfo, CdpError> {
570 let base = self.base_url().await;
571 let endpoint = match url {
572 Some(u) => format!("{}/json/new?{}", base, u),
573 None => format!("{}/json/new", base),
574 };
575
576 let resp = self
577 .client
578 .get(&endpoint)
579 .send()
580 .await
581 .map_err(|e| CdpError::Http(e.to_string()))?;
582
583 if !resp.status().is_success() {
584 return Err(CdpError::UnexpectedResponse {
585 detail: format!("POST /json/new returned {}", resp.status()),
586 });
587 }
588
589 let target: CdpTargetInfo = resp
590 .json()
591 .await
592 .map_err(|e| CdpError::UnexpectedResponse {
593 detail: format!("failed to parse target info: {}", e),
594 })?;
595
596 debug!(target_id = %target.id, ws_url = %target.web_socket_debugger_url, "created new tab");
597 Ok(target)
598 }
599
600 #[allow(dead_code)]
602 async fn list_tabs(&self) -> Result<Vec<CdpTargetInfo>, CdpError> {
603 let base = self.base_url().await;
604 let url = format!("{}/json/list", base);
605
606 let resp = self
607 .client
608 .get(&url)
609 .send()
610 .await
611 .map_err(|e| CdpError::Http(e.to_string()))?;
612
613 let targets: Vec<CdpTargetInfo> =
614 resp.json()
615 .await
616 .map_err(|e| CdpError::UnexpectedResponse {
617 detail: format!("failed to parse tab list: {}", e),
618 })?;
619
620 Ok(targets)
621 }
622
623 async fn close_tab(&self, target_id: &str) -> Result<(), CdpError> {
625 let base = self.base_url().await;
626 let url = format!("{}/json/close/{}", base, target_id);
627
628 let resp = self
629 .client
630 .get(&url)
631 .send()
632 .await
633 .map_err(|e| CdpError::Http(e.to_string()))?;
634
635 if !resp.status().is_success() {
636 warn!(target_id = %target_id, status = %resp.status(), "failed to close tab");
637 }
638
639 Ok(())
640 }
641
642 #[allow(dead_code)]
644 async fn activate_tab(&self, target_id: &str) -> Result<(), CdpError> {
645 let base = self.base_url().await;
646 let url = format!("{}/json/activate/{}", base, target_id);
647
648 self.client
649 .get(&url)
650 .send()
651 .await
652 .map_err(|e| CdpError::Http(e.to_string()))?;
653
654 Ok(())
655 }
656
657 fn get_cdp_session(&self, session_id: &str) -> Result<CdpSession, CdpError> {
659 self.sessions
660 .get(session_id)
661 .map(|entry| entry.value().clone())
662 .ok_or_else(|| CdpError::SessionNotFound {
663 session_id: session_id.to_string(),
664 })
665 }
666
667 async fn execute_navigate(
669 &self,
670 session: &mut BrowserSession,
671 url: &str,
672 ) -> PunchResult<BrowserResult> {
673 let start = Instant::now();
674 session.state = BrowserState::Navigating;
675
676 let cdp_session = self.get_cdp_session(&session.id.to_string())?;
677
678 self.close_tab(&cdp_session.target_id).await?;
682
683 let target = self.create_tab(Some(url)).await?;
684
685 let new_cdp_session = CdpSession {
687 id: session.id.to_string(),
688 target_id: target.id.clone(),
689 ws_url: target.web_socket_debugger_url.clone(),
690 created_at: cdp_session.created_at,
691 };
692 self.sessions
693 .insert(session.id.to_string(), new_cdp_session);
694
695 session.current_url = Some(url.to_string());
696 session.page_title = Some(target.title.clone());
697 session.state = BrowserState::Ready;
698
699 let duration = start.elapsed().as_millis() as u64;
700 let mut result = BrowserResult::ok(serde_json::json!({
701 "navigated": url,
702 "title": target.title,
703 }));
704 result.page_url = Some(url.to_string());
705 result.page_title = Some(target.title);
706 result.duration_ms = duration;
707
708 Ok(result)
709 }
710
711 async fn execute_screenshot(
713 &self,
714 session: &BrowserSession,
715 _full_page: bool,
716 ) -> PunchResult<BrowserResult> {
717 let start = Instant::now();
718 let _cdp_session = self.get_cdp_session(&session.id.to_string())?;
719
720 let cmd_id = self.next_id();
724 let cmd = build_screenshot_command(cmd_id, _full_page);
725 let cmd_json = cmd.to_json()?;
726
727 let duration = start.elapsed().as_millis() as u64;
728 let mut result = BrowserResult::ok(serde_json::json!({
729 "command_sent": cmd_json,
730 "note": "screenshot capture requires WebSocket CDP connection",
731 }));
732 result.page_url = session.current_url.clone();
733 result.duration_ms = duration;
734
735 Ok(result)
736 }
737
738 async fn execute_evaluate(
740 &self,
741 session: &BrowserSession,
742 javascript: &str,
743 ) -> PunchResult<BrowserResult> {
744 let start = Instant::now();
745 let _cdp_session = self.get_cdp_session(&session.id.to_string())?;
746
747 let cmd_id = self.next_id();
748 let cmd = build_evaluate_command(cmd_id, javascript);
749 let cmd_json = cmd.to_json()?;
750
751 let duration = start.elapsed().as_millis() as u64;
752 let mut result = BrowserResult::ok(serde_json::json!({
753 "command_sent": cmd_json,
754 "note": "script evaluation requires WebSocket CDP connection",
755 }));
756 result.page_url = session.current_url.clone();
757 result.duration_ms = duration;
758
759 Ok(result)
760 }
761
762 async fn execute_click(
764 &self,
765 session: &BrowserSession,
766 selector: &str,
767 ) -> PunchResult<BrowserResult> {
768 let start = Instant::now();
769 let _cdp_session = self.get_cdp_session(&session.id.to_string())?;
770
771 let cmd_id = self.next_id();
772 let cmd = build_click_command(cmd_id, selector);
773 let cmd_json = cmd.to_json()?;
774
775 let duration = start.elapsed().as_millis() as u64;
776 let mut result = BrowserResult::ok(serde_json::json!({
777 "command_sent": cmd_json,
778 "selector": selector,
779 }));
780 result.page_url = session.current_url.clone();
781 result.duration_ms = duration;
782
783 Ok(result)
784 }
785
786 async fn execute_type(
788 &self,
789 session: &BrowserSession,
790 selector: &str,
791 text: &str,
792 ) -> PunchResult<BrowserResult> {
793 let start = Instant::now();
794 let _cdp_session = self.get_cdp_session(&session.id.to_string())?;
795
796 let cmd_id = self.next_id();
797 let cmd = build_type_text_command(cmd_id, selector, text);
798 let cmd_json = cmd.to_json()?;
799
800 let duration = start.elapsed().as_millis() as u64;
801 let mut result = BrowserResult::ok(serde_json::json!({
802 "command_sent": cmd_json,
803 "selector": selector,
804 }));
805 result.page_url = session.current_url.clone();
806 result.duration_ms = duration;
807
808 Ok(result)
809 }
810
811 async fn execute_get_content(
813 &self,
814 session: &BrowserSession,
815 selector: Option<&str>,
816 ) -> PunchResult<BrowserResult> {
817 let start = Instant::now();
818 let _cdp_session = self.get_cdp_session(&session.id.to_string())?;
819
820 let cmd_id = self.next_id();
821 let cmd = build_get_content_command(cmd_id, selector);
822 let cmd_json = cmd.to_json()?;
823
824 let duration = start.elapsed().as_millis() as u64;
825 let mut result = BrowserResult::ok(serde_json::json!({
826 "command_sent": cmd_json,
827 }));
828 result.page_url = session.current_url.clone();
829 result.duration_ms = duration;
830
831 Ok(result)
832 }
833
834 async fn execute_get_html(
836 &self,
837 session: &BrowserSession,
838 selector: Option<&str>,
839 ) -> PunchResult<BrowserResult> {
840 let start = Instant::now();
841 let _cdp_session = self.get_cdp_session(&session.id.to_string())?;
842
843 let cmd_id = self.next_id();
844 let cmd = build_get_html_command(cmd_id, selector);
845 let cmd_json = cmd.to_json()?;
846
847 let duration = start.elapsed().as_millis() as u64;
848 let mut result = BrowserResult::ok(serde_json::json!({
849 "command_sent": cmd_json,
850 }));
851 result.page_url = session.current_url.clone();
852 result.duration_ms = duration;
853
854 Ok(result)
855 }
856
857 async fn execute_wait_for_selector(
859 &self,
860 session: &BrowserSession,
861 selector: &str,
862 timeout_ms: u64,
863 ) -> PunchResult<BrowserResult> {
864 let start = Instant::now();
865 let _cdp_session = self.get_cdp_session(&session.id.to_string())?;
866
867 let cmd_id = self.next_id();
868 let cmd = build_wait_for_selector_command(cmd_id, selector, timeout_ms);
869 let cmd_json = cmd.to_json()?;
870
871 let duration = start.elapsed().as_millis() as u64;
872 let mut result = BrowserResult::ok(serde_json::json!({
873 "command_sent": cmd_json,
874 "selector": selector,
875 "timeout_ms": timeout_ms,
876 }));
877 result.page_url = session.current_url.clone();
878 result.duration_ms = duration;
879
880 Ok(result)
881 }
882
883 pub async fn shutdown(&self) -> PunchResult<()> {
885 info!("shutting down CDP browser driver");
886
887 let session_ids: Vec<String> = self
889 .sessions
890 .iter()
891 .map(|entry| entry.key().clone())
892 .collect();
893
894 for session_id in &session_ids {
895 if let Some((_, cdp_session)) = self.sessions.remove(session_id) {
896 let _ = self.close_tab(&cdp_session.target_id).await;
897 }
898 }
899
900 let mut process = self.chrome_process.lock().await;
902 if let Some(ref mut child) = *process {
903 info!("killing Chrome process");
904 if let Err(e) = child.kill().await {
905 warn!(error = %e, "failed to kill Chrome process");
906 }
907 }
908 *process = None;
909
910 Ok(())
911 }
912}
913
914#[async_trait]
919impl BrowserDriver for CdpBrowserDriver {
920 async fn launch(&self, config: &BrowserConfig) -> PunchResult<BrowserSession> {
921 let mut port_lock = self.active_port.lock().await;
923 *port_lock = Some(config.remote_debugging_port);
924 drop(port_lock);
925
926 let child = self.launch_chrome().await?;
928 let mut process_lock = self.chrome_process.lock().await;
929 *process_lock = Some(child);
930 drop(process_lock);
931
932 self.wait_for_cdp_ready().await?;
934
935 let target = self.create_tab(None).await?;
937
938 let mut session = BrowserSession::new();
939 session.state = BrowserState::Connected;
940 session.current_url = Some(target.url.clone());
941
942 let cdp_session = CdpSession {
943 id: session.id.to_string(),
944 target_id: target.id,
945 ws_url: target.web_socket_debugger_url,
946 created_at: Utc::now(),
947 };
948 self.sessions.insert(session.id.to_string(), cdp_session);
949
950 info!(session_id = %session.id, "browser session launched");
951 Ok(session)
952 }
953
954 async fn execute(
955 &self,
956 session: &mut BrowserSession,
957 action: BrowserAction,
958 ) -> PunchResult<BrowserResult> {
959 match action {
960 BrowserAction::Navigate { url } => self.execute_navigate(session, &url).await,
961 BrowserAction::Click { selector } => self.execute_click(session, &selector).await,
962 BrowserAction::Type { selector, text } => {
963 self.execute_type(session, &selector, &text).await
964 }
965 BrowserAction::Screenshot { full_page } => {
966 self.execute_screenshot(session, full_page).await
967 }
968 BrowserAction::GetContent { selector } => {
969 self.execute_get_content(session, selector.as_deref())
970 .await
971 }
972 BrowserAction::GetHtml { selector } => {
973 self.execute_get_html(session, selector.as_deref()).await
974 }
975 BrowserAction::WaitForSelector {
976 selector,
977 timeout_ms,
978 } => {
979 self.execute_wait_for_selector(session, &selector, timeout_ms)
980 .await
981 }
982 BrowserAction::Evaluate { javascript } => {
983 self.execute_evaluate(session, &javascript).await
984 }
985 BrowserAction::GoBack => {
986 self.execute_evaluate(session, "window.history.back()")
987 .await
988 }
989 BrowserAction::GoForward => {
990 self.execute_evaluate(session, "window.history.forward()")
991 .await
992 }
993 BrowserAction::Reload => {
994 self.execute_evaluate(session, "window.location.reload()")
995 .await
996 }
997 BrowserAction::Close => {
998 self.close(session).await?;
999 Ok(BrowserResult::ok(serde_json::json!({"closed": true})))
1000 }
1001 }
1002 }
1003
1004 async fn close(&self, session: &mut BrowserSession) -> PunchResult<()> {
1005 let session_id = session.id.to_string();
1006 if let Some((_, cdp_session)) = self.sessions.remove(&session_id) {
1007 let _ = self.close_tab(&cdp_session.target_id).await;
1008 }
1009 session.state = BrowserState::Closed;
1010 info!(session_id = %session_id, "browser session closed");
1011 Ok(())
1012 }
1013}
1014
1015#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use uuid::Uuid;
1023
1024 #[test]
1027 fn test_cdp_config_defaults() {
1028 let config = CdpConfig::default();
1029 assert!(config.chrome_path.is_none());
1030 assert_eq!(config.debug_port, 9222);
1031 assert!(config.headless);
1032 assert!(config.user_data_dir.is_none());
1033 assert!(config.extra_args.is_empty());
1034 assert_eq!(config.connect_timeout_secs, 10);
1035 assert!(config.disable_gpu);
1036 assert!(config.no_sandbox);
1037 }
1038
1039 #[test]
1040 fn test_cdp_config_from_browser_config() {
1041 let browser_config = BrowserConfig {
1042 chrome_path: Some("/usr/bin/chromium".into()),
1043 headless: false,
1044 remote_debugging_port: 9333,
1045 user_data_dir: Some("/tmp/chrome-test".into()),
1046 timeout_secs: 60,
1047 viewport_width: 1920,
1048 viewport_height: 1080,
1049 };
1050
1051 let cdp_config = CdpConfig::from(&browser_config);
1052 assert_eq!(cdp_config.chrome_path.as_deref(), Some("/usr/bin/chromium"));
1053 assert_eq!(cdp_config.debug_port, 9333);
1054 assert!(!cdp_config.headless);
1055 assert_eq!(
1056 cdp_config.user_data_dir.as_deref(),
1057 Some("/tmp/chrome-test")
1058 );
1059 }
1060
1061 #[test]
1062 fn test_cdp_config_serialization_roundtrip() {
1063 let config = CdpConfig {
1064 chrome_path: Some("/usr/bin/google-chrome".into()),
1065 debug_port: 9333,
1066 headless: false,
1067 user_data_dir: Some("/tmp/data".into()),
1068 extra_args: vec!["--disable-extensions".into()],
1069 connect_timeout_secs: 20,
1070 disable_gpu: false,
1071 no_sandbox: false,
1072 };
1073
1074 let json = serde_json::to_string(&config).expect("should serialize");
1075 let deserialized: CdpConfig =
1076 serde_json::from_str(&json).expect("should deserialize");
1077
1078 assert_eq!(
1079 deserialized.chrome_path.as_deref(),
1080 Some("/usr/bin/google-chrome")
1081 );
1082 assert_eq!(deserialized.debug_port, 9333);
1083 assert!(!deserialized.headless);
1084 assert_eq!(deserialized.extra_args.len(), 1);
1085 }
1086
1087 #[test]
1090 fn test_chrome_candidate_paths_not_empty() {
1091 let paths = chrome_candidate_paths();
1092 assert!(
1094 !paths.is_empty(),
1095 "should have at least one candidate Chrome path"
1096 );
1097 }
1098
1099 #[test]
1100 fn test_find_chrome_returns_existing_path_or_none() {
1101 let result = find_chrome();
1103 if let Some(ref path) = result {
1104 assert!(
1105 std::path::Path::new(path).exists(),
1106 "found path should exist: {}",
1107 path
1108 );
1109 }
1110 }
1112
1113 #[test]
1116 fn test_cdp_session_creation() {
1117 let session = CdpSession {
1118 id: "test-session-123".into(),
1119 target_id: "ABCD1234".into(),
1120 ws_url: "ws://localhost:9222/devtools/page/ABCD1234".into(),
1121 created_at: Utc::now(),
1122 };
1123
1124 assert_eq!(session.id, "test-session-123");
1125 assert_eq!(session.target_id, "ABCD1234");
1126 assert!(session.ws_url.contains("devtools/page"));
1127 }
1128
1129 #[test]
1130 fn test_cdp_session_serialization() {
1131 let session = CdpSession {
1132 id: "s1".into(),
1133 target_id: "t1".into(),
1134 ws_url: "ws://localhost:9222/devtools/page/t1".into(),
1135 created_at: Utc::now(),
1136 };
1137
1138 let json = serde_json::to_string(&session).expect("should serialize");
1139 let deserialized: CdpSession =
1140 serde_json::from_str(&json).expect("should deserialize");
1141
1142 assert_eq!(deserialized.id, "s1");
1143 assert_eq!(deserialized.target_id, "t1");
1144 }
1145
1146 #[test]
1149 fn test_navigate_command_format() {
1150 let cmd = build_navigate_command(1, "https://example.com");
1151 assert_eq!(cmd.id, 1);
1152 assert_eq!(cmd.method, "Page.navigate");
1153 assert_eq!(cmd.params["url"], "https://example.com");
1154
1155 let json = cmd.to_json().expect("should serialize");
1156 assert!(json.contains("Page.navigate"));
1157 assert!(json.contains("https://example.com"));
1158 }
1159
1160 #[test]
1161 fn test_screenshot_command_format() {
1162 let cmd = build_screenshot_command(2, false);
1163 assert_eq!(cmd.id, 2);
1164 assert_eq!(cmd.method, "Page.captureScreenshot");
1165
1166 let json = cmd.to_json().expect("should serialize");
1167 assert!(json.contains("Page.captureScreenshot"));
1168
1169 let full_cmd = build_screenshot_command(3, true);
1170 let full_json = full_cmd.to_json().expect("should serialize");
1171 assert!(full_json.contains("captureBeyondViewport"));
1172 }
1173
1174 #[test]
1175 fn test_evaluate_command_format() {
1176 let cmd = build_evaluate_command(4, "document.title");
1177 assert_eq!(cmd.id, 4);
1178 assert_eq!(cmd.method, "Runtime.evaluate");
1179 assert_eq!(cmd.params["expression"], "document.title");
1180 assert_eq!(cmd.params["returnByValue"], true);
1181 assert_eq!(cmd.params["awaitPromise"], true);
1182 }
1183
1184 #[test]
1185 fn test_click_command_format() {
1186 let cmd = build_click_command(5, "#submit-btn");
1187 let json = cmd.to_json().expect("should serialize");
1188 assert!(json.contains("Runtime.evaluate"));
1189 assert!(json.contains("querySelector"));
1190 assert!(json.contains("#submit-btn"));
1191 assert!(json.contains("click()"));
1192 }
1193
1194 #[test]
1195 fn test_get_content_command_with_selector() {
1196 let cmd = build_get_content_command(6, Some("h1.title"));
1197 let json = cmd.to_json().expect("should serialize");
1198 assert!(json.contains("Runtime.evaluate"));
1199 assert!(json.contains("textContent"));
1200 assert!(json.contains("h1.title"));
1201 }
1202
1203 #[test]
1204 fn test_get_content_command_without_selector() {
1205 let cmd = build_get_content_command(7, None);
1206 let json = cmd.to_json().expect("should serialize");
1207 assert!(json.contains("document.body.innerText"));
1208 }
1209
1210 #[test]
1211 fn test_type_text_command_format() {
1212 let cmd = build_type_text_command(8, "input#search", "hello world");
1213 let json = cmd.to_json().expect("should serialize");
1214 assert!(json.contains("Runtime.evaluate"));
1215 assert!(json.contains("input#search"));
1216 assert!(json.contains("hello world"));
1217 assert!(json.contains("dispatchEvent"));
1218 }
1219
1220 #[test]
1221 fn test_wait_for_selector_command_format() {
1222 let cmd = build_wait_for_selector_command(9, ".loaded", 5000);
1223 let json = cmd.to_json().expect("should serialize");
1224 assert!(json.contains("Runtime.evaluate"));
1225 assert!(json.contains(".loaded"));
1226 assert!(json.contains("5000"));
1227 }
1228
1229 #[test]
1230 fn test_get_html_command_with_selector() {
1231 let cmd = build_get_html_command(10, Some("div.content"));
1232 let json = cmd.to_json().expect("should serialize");
1233 assert!(json.contains("outerHTML"));
1234 assert!(json.contains("div.content"));
1235 }
1236
1237 #[test]
1238 fn test_get_html_command_without_selector() {
1239 let cmd = build_get_html_command(11, None);
1240 let json = cmd.to_json().expect("should serialize");
1241 assert!(json.contains("document.documentElement.outerHTML"));
1242 }
1243
1244 #[test]
1247 fn test_cdp_command_new() {
1248 let cmd = CdpCommand::new(42, "DOM.getDocument", serde_json::json!({"depth": 1}));
1249 assert_eq!(cmd.id, 42);
1250 assert_eq!(cmd.method, "DOM.getDocument");
1251 assert_eq!(cmd.params["depth"], 1);
1252 }
1253
1254 #[test]
1255 fn test_cdp_response_parse_success() {
1256 let json = r#"{"id": 1, "result": {"frameId": "abc123"}}"#;
1257 let resp: CdpResponse = serde_json::from_str(json).expect("should parse");
1258 assert_eq!(resp.id, Some(1));
1259 assert!(resp.result.is_some());
1260 assert!(resp.error.is_none());
1261 assert_eq!(resp.result.unwrap()["frameId"], "abc123");
1262 }
1263
1264 #[test]
1265 fn test_cdp_response_parse_error() {
1266 let json =
1267 r#"{"id": 2, "error": {"code": -32601, "message": "method not found"}}"#;
1268 let resp: CdpResponse = serde_json::from_str(json).expect("should parse");
1269 assert_eq!(resp.id, Some(2));
1270 assert!(resp.result.is_none());
1271 assert!(resp.error.is_some());
1272 let err = resp.error.unwrap();
1273 assert_eq!(err.code, -32601);
1274 assert_eq!(err.message, "method not found");
1275 }
1276
1277 #[test]
1280 fn test_cdp_target_info_parse() {
1281 let json = r#"{
1282 "description": "",
1283 "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/ABC",
1284 "id": "ABC123",
1285 "title": "about:blank",
1286 "type": "page",
1287 "url": "about:blank",
1288 "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/ABC123"
1289 }"#;
1290
1291 let target: CdpTargetInfo = serde_json::from_str(json).expect("should parse");
1292 assert_eq!(target.id, "ABC123");
1293 assert_eq!(target.target_type, "page");
1294 assert_eq!(target.url, "about:blank");
1295 assert!(target.web_socket_debugger_url.contains("ws://"));
1296 }
1297
1298 #[test]
1301 fn test_cdp_error_to_punch_error() {
1302 let cdp_err = CdpError::ChromeNotFound {
1303 searched_paths: vec!["/usr/bin/chrome".into()],
1304 };
1305 let punch_err: PunchError = cdp_err.into();
1306 let msg = punch_err.to_string();
1307 assert!(msg.contains("browser_cdp"), "error: {}", msg);
1308 assert!(msg.contains("Chrome binary not found"), "error: {}", msg);
1309 }
1310
1311 #[test]
1312 fn test_cdp_error_variants() {
1313 let errors: Vec<CdpError> = vec![
1315 CdpError::ChromeNotFound {
1316 searched_paths: vec![],
1317 },
1318 CdpError::LaunchFailed {
1319 reason: "permission denied".into(),
1320 },
1321 CdpError::ConnectionFailed {
1322 port: 9222,
1323 reason: "refused".into(),
1324 },
1325 CdpError::CommandError {
1326 command_id: 1,
1327 message: "eval failed".into(),
1328 },
1329 CdpError::SessionNotFound {
1330 session_id: "abc".into(),
1331 },
1332 CdpError::UnexpectedResponse {
1333 detail: "bad json".into(),
1334 },
1335 CdpError::Timeout { timeout_secs: 30 },
1336 CdpError::Http("connection reset".into()),
1337 ];
1338
1339 for err in &errors {
1340 let msg = err.to_string();
1341 assert!(!msg.is_empty(), "error message should not be empty");
1342 }
1343 }
1344
1345 #[test]
1348 fn test_cdp_browser_driver_implements_trait() {
1349 fn _assert_browser_driver<T: BrowserDriver>() {}
1352 _assert_browser_driver::<CdpBrowserDriver>();
1353 }
1354
1355 #[test]
1356 fn test_cdp_browser_driver_is_send_sync() {
1357 fn _assert_send_sync<T: Send + Sync>() {}
1358 _assert_send_sync::<CdpBrowserDriver>();
1359 }
1360
1361 #[test]
1364 fn test_cdp_driver_creation() {
1365 let driver = CdpBrowserDriver::new(CdpConfig::default());
1366 assert_eq!(driver.sessions.len(), 0);
1367 }
1368
1369 #[test]
1370 fn test_cdp_driver_with_defaults() {
1371 let driver = CdpBrowserDriver::with_defaults();
1372 assert_eq!(driver.sessions.len(), 0);
1373 }
1374
1375 #[test]
1376 fn test_cdp_driver_chrome_args() {
1377 let config = CdpConfig {
1378 debug_port: 9333,
1379 headless: true,
1380 disable_gpu: true,
1381 no_sandbox: true,
1382 user_data_dir: Some("/tmp/test-data".into()),
1383 extra_args: vec!["--disable-extensions".into()],
1384 ..Default::default()
1385 };
1386 let driver = CdpBrowserDriver::new(config);
1387 let args = driver.build_chrome_args();
1388
1389 assert!(args.contains(&"--remote-debugging-port=9333".to_string()));
1390 assert!(args.contains(&"--headless".to_string()));
1391 assert!(args.contains(&"--disable-gpu".to_string()));
1392 assert!(args.contains(&"--no-sandbox".to_string()));
1393 assert!(args.contains(&"--user-data-dir=/tmp/test-data".to_string()));
1394 assert!(args.contains(&"--disable-extensions".to_string()));
1395 assert!(args.contains(&"about:blank".to_string()));
1396 }
1397
1398 #[test]
1399 fn test_cdp_driver_chrome_args_minimal() {
1400 let config = CdpConfig {
1401 headless: false,
1402 disable_gpu: false,
1403 no_sandbox: false,
1404 user_data_dir: None,
1405 extra_args: vec![],
1406 ..Default::default()
1407 };
1408 let driver = CdpBrowserDriver::new(config);
1409 let args = driver.build_chrome_args();
1410
1411 assert!(!args.contains(&"--headless".to_string()));
1412 assert!(!args.contains(&"--disable-gpu".to_string()));
1413 assert!(!args.contains(&"--no-sandbox".to_string()));
1414 assert!(args
1416 .iter()
1417 .any(|a| a.starts_with("--remote-debugging-port=")));
1418 assert!(args.contains(&"about:blank".to_string()));
1419 }
1420
1421 #[test]
1424 fn test_cdp_driver_session_tracking() {
1425 let driver = CdpBrowserDriver::with_defaults();
1426
1427 let session_id = Uuid::new_v4().to_string();
1429 let cdp_session = CdpSession {
1430 id: session_id.clone(),
1431 target_id: "target_001".into(),
1432 ws_url: "ws://localhost:9222/devtools/page/target_001".into(),
1433 created_at: Utc::now(),
1434 };
1435 driver.sessions.insert(session_id.clone(), cdp_session);
1436
1437 assert_eq!(driver.sessions.len(), 1);
1438 let retrieved = driver.get_cdp_session(&session_id);
1439 assert!(retrieved.is_ok());
1440 assert_eq!(retrieved.unwrap().target_id, "target_001");
1441 }
1442
1443 #[test]
1444 fn test_cdp_driver_session_not_found() {
1445 let driver = CdpBrowserDriver::with_defaults();
1446 let result = driver.get_cdp_session("nonexistent-id");
1447 assert!(result.is_err());
1448 match result.unwrap_err() {
1449 CdpError::SessionNotFound { session_id } => {
1450 assert_eq!(session_id, "nonexistent-id");
1451 }
1452 other => panic!("expected SessionNotFound, got: {:?}", other),
1453 }
1454 }
1455
1456 #[test]
1457 fn test_cdp_driver_multiple_sessions() {
1458 let driver = CdpBrowserDriver::with_defaults();
1459
1460 for i in 0..5 {
1461 let session_id = format!("session_{}", i);
1462 let cdp_session = CdpSession {
1463 id: session_id.clone(),
1464 target_id: format!("target_{}", i),
1465 ws_url: format!("ws://localhost:9222/devtools/page/target_{}", i),
1466 created_at: Utc::now(),
1467 };
1468 driver.sessions.insert(session_id, cdp_session);
1469 }
1470
1471 assert_eq!(driver.sessions.len(), 5);
1472
1473 driver.sessions.remove("session_2");
1475 assert_eq!(driver.sessions.len(), 4);
1476 assert!(driver.get_cdp_session("session_2").is_err());
1477 assert!(driver.get_cdp_session("session_3").is_ok());
1478 }
1479
1480 #[test]
1481 fn test_cdp_driver_next_id_increments() {
1482 let driver = CdpBrowserDriver::with_defaults();
1483 let id1 = driver.next_id();
1484 let id2 = driver.next_id();
1485 let id3 = driver.next_id();
1486
1487 assert_eq!(id1, 1);
1488 assert_eq!(id2, 2);
1489 assert_eq!(id3, 3);
1490 }
1491
1492 #[test]
1495 fn test_resolve_chrome_path_with_config() {
1496 let config = CdpConfig {
1497 chrome_path: Some("/custom/path/chrome".into()),
1498 ..Default::default()
1499 };
1500 let driver = CdpBrowserDriver::new(config);
1501 let path = driver.resolve_chrome_path();
1502 assert!(path.is_ok());
1503 assert_eq!(path.unwrap(), "/custom/path/chrome");
1504 }
1505}