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