Skip to main content

punch_types/
cdp.rs

1//! Chrome DevTools Protocol (CDP) browser driver — the real steel behind browser automation.
2//!
3//! This module implements a CDP client that communicates with Chrome/Chromium
4//! via its remote debugging protocol. It launches a Chrome process, manages
5//! tabs through the `/json/*` HTTP management endpoints, and executes CDP
6//! commands via WebSocket-style JSON-RPC messages forwarded over HTTP.
7//!
8//! The driver implements the `BrowserDriver` trait so it can be plugged into
9//! the `BrowserPool` seamlessly — a real heavyweight stepping into the ring.
10
11use 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// ---------------------------------------------------------------------------
30// CDP-specific configuration
31// ---------------------------------------------------------------------------
32
33/// Configuration specific to the CDP browser driver.
34///
35/// Extends `BrowserConfig` with CDP-specific knobs — fine-tuning the
36/// fighter's gloves before the bout.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CdpConfig {
39    /// Path to the Chrome/Chromium binary. `None` means auto-detect.
40    pub chrome_path: Option<String>,
41    /// Remote debugging port. Default: `9222`.
42    pub debug_port: u16,
43    /// Run headless (no visible window). Default: `true`.
44    pub headless: bool,
45    /// Custom user-data directory. `None` uses a temp directory.
46    pub user_data_dir: Option<String>,
47    /// Additional Chrome launch arguments.
48    pub extra_args: Vec<String>,
49    /// Connection timeout in seconds. Default: `10`.
50    pub connect_timeout_secs: u64,
51    /// Whether to disable GPU acceleration. Default: `true`.
52    pub disable_gpu: bool,
53    /// Whether to run with `--no-sandbox`. Default: `true`.
54    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// ---------------------------------------------------------------------------
85// CDP errors
86// ---------------------------------------------------------------------------
87
88/// CDP-specific errors — when the fighter takes a hit in the browser ring.
89#[derive(Debug, thiserror::Error)]
90pub enum CdpError {
91    /// Chrome binary not found on the system.
92    #[error("Chrome binary not found; searched: {searched_paths:?}")]
93    ChromeNotFound { searched_paths: Vec<String> },
94
95    /// Failed to launch the Chrome process.
96    #[error("failed to launch Chrome: {reason}")]
97    LaunchFailed { reason: String },
98
99    /// Could not connect to the CDP debug endpoint.
100    #[error("failed to connect to CDP on port {port}: {reason}")]
101    ConnectionFailed { port: u16, reason: String },
102
103    /// A CDP command returned an error.
104    #[error("CDP command error (id={command_id}): {message}")]
105    CommandError { command_id: u64, message: String },
106
107    /// Session not found in the driver's tracking map.
108    #[error("CDP session not found: {session_id}")]
109    SessionNotFound { session_id: String },
110
111    /// The CDP endpoint returned an unexpected response.
112    #[error("unexpected CDP response: {detail}")]
113    UnexpectedResponse { detail: String },
114
115    /// Timeout waiting for a CDP operation.
116    #[error("CDP operation timed out after {timeout_secs}s")]
117    Timeout { timeout_secs: u64 },
118
119    /// HTTP request to the CDP endpoint failed.
120    #[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// ---------------------------------------------------------------------------
134// CDP session tracking
135// ---------------------------------------------------------------------------
136
137/// Internal tracking data for a CDP tab/target — the fighter's corner intel.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct CdpSession {
140    /// Our internal session UUID (matches `BrowserSession.id`).
141    pub id: String,
142    /// The CDP target ID returned by Chrome.
143    pub target_id: String,
144    /// The WebSocket debugger URL for this target.
145    pub ws_url: String,
146    /// When this CDP session was created.
147    pub created_at: chrono::DateTime<chrono::Utc>,
148}
149
150/// Response from `GET /json/new` and `GET /json/list`.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub struct CdpTargetInfo {
154    /// Target description (usually empty for pages).
155    #[serde(default)]
156    pub description: String,
157    /// DevTools frontend URL.
158    #[serde(default)]
159    pub devtools_frontend_url: String,
160    /// Unique target identifier.
161    pub id: String,
162    /// Page title.
163    #[serde(default)]
164    pub title: String,
165    /// Target type (e.g. "page", "background_page").
166    #[serde(default, rename = "type")]
167    pub target_type: String,
168    /// Current URL loaded in the target.
169    #[serde(default)]
170    pub url: String,
171    /// WebSocket debugger URL for direct CDP communication.
172    #[serde(default)]
173    pub web_socket_debugger_url: String,
174}
175
176// ---------------------------------------------------------------------------
177// CDP command / response structures
178// ---------------------------------------------------------------------------
179
180/// A CDP JSON-RPC command — the punch being thrown.
181#[derive(Debug, Clone, Serialize)]
182pub struct CdpCommand {
183    /// Monotonically increasing command ID.
184    pub id: u64,
185    /// CDP method name (e.g. "Page.navigate", "Runtime.evaluate").
186    pub method: String,
187    /// Method parameters.
188    pub params: serde_json::Value,
189}
190
191impl CdpCommand {
192    /// Create a new CDP command with the given method and params.
193    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    /// Serialize to JSON string.
202    pub fn to_json(&self) -> PunchResult<String> {
203        serde_json::to_string(self).map_err(PunchError::from)
204    }
205}
206
207/// A CDP JSON-RPC response.
208#[derive(Debug, Clone, Deserialize)]
209pub struct CdpResponse {
210    /// The command ID this is responding to.
211    pub id: Option<u64>,
212    /// The result payload (present on success).
213    pub result: Option<serde_json::Value>,
214    /// Error information (present on failure).
215    pub error: Option<CdpResponseError>,
216}
217
218/// Error payload within a CDP response.
219#[derive(Debug, Clone, Deserialize)]
220pub struct CdpResponseError {
221    /// Error code.
222    pub code: i64,
223    /// Human-readable error message.
224    pub message: String,
225    /// Additional error data.
226    pub data: Option<serde_json::Value>,
227}
228
229// ---------------------------------------------------------------------------
230// Chrome path detection
231// ---------------------------------------------------------------------------
232
233/// Attempt to find a Chrome or Chromium binary on the current system.
234///
235/// Checks well-known installation paths on macOS, Linux, and Windows.
236/// Returns the first path that exists, or `None` if Chrome is not found.
237pub 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
249/// Return the list of candidate Chrome/Chromium paths for the current platform.
250pub fn chrome_candidate_paths() -> Vec<String> {
251    let mut paths = Vec::new();
252
253    // macOS paths
254    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    // Linux paths
265    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    // Windows paths
277    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
288// ---------------------------------------------------------------------------
289// CDP command builders
290// ---------------------------------------------------------------------------
291
292/// Build a `Page.navigate` CDP command.
293pub 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
301/// Build a `Page.captureScreenshot` CDP command.
302pub 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
311/// Build a `Runtime.evaluate` CDP command.
312pub 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
324/// Build a `Runtime.evaluate` command for clicking an element by selector.
325pub 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
338/// Build a `Runtime.evaluate` command for getting element text content.
339pub 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
357/// Build a `Runtime.evaluate` command for getting element HTML.
358pub 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
376/// Build a `Runtime.evaluate` command for typing text into an element.
377pub 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
396/// Build a `Runtime.evaluate` command for waiting for a selector.
397pub 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
418// ---------------------------------------------------------------------------
419// CDP Browser Driver
420// ---------------------------------------------------------------------------
421
422/// A real CDP browser driver that communicates with Chrome/Chromium.
423///
424/// This is the heavyweight champion — it launches Chrome, manages tabs
425/// through the `/json/*` HTTP endpoints, and executes CDP commands to
426/// drive browser automation.
427pub struct CdpBrowserDriver {
428    /// HTTP client for CDP management endpoints.
429    client: reqwest::Client,
430    /// Active CDP sessions, keyed by our internal session UUID string.
431    sessions: DashMap<String, CdpSession>,
432    /// CDP configuration.
433    config: CdpConfig,
434    /// Monotonically increasing command ID counter.
435    command_counter: AtomicU64,
436    /// The Chrome child process, if we launched it.
437    chrome_process: Arc<Mutex<Option<Child>>>,
438    /// The debug port actually in use (may differ from config if auto-assigned).
439    active_port: Arc<Mutex<Option<u16>>>,
440}
441
442impl CdpBrowserDriver {
443    /// Create a new CDP driver with the given configuration.
444    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    /// Create a driver with default configuration.
456    pub fn with_defaults() -> Self {
457        Self::new(CdpConfig::default())
458    }
459
460    /// Get the next command ID.
461    fn next_id(&self) -> u64 {
462        self.command_counter.fetch_add(1, Ordering::Relaxed)
463    }
464
465    /// Get the debug port (active or configured).
466    async fn debug_port(&self) -> u16 {
467        self.active_port
468            .lock()
469            .await
470            .unwrap_or(self.config.debug_port)
471    }
472
473    /// Build the base URL for CDP HTTP management endpoints.
474    async fn base_url(&self) -> String {
475        format!("http://localhost:{}", self.debug_port().await)
476    }
477
478    /// Resolve the Chrome binary path (from config or auto-detect).
479    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    /// Build Chrome launch arguments.
489    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        // Start with about:blank to avoid loading any default page.
511        args.push("about:blank".to_string());
512
513        args
514    }
515
516    /// Launch Chrome as a child process.
517    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    /// Wait for Chrome's CDP endpoint to become available.
538    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    /// Create a new tab via the CDP `/json/new` endpoint.
569    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    /// List all open tabs/targets via `/json/list`.
601    #[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    /// Close a tab via `/json/close/{targetId}`.
624    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    /// Activate (bring to front) a tab via `/json/activate/{targetId}`.
643    #[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    /// Look up a CDP session by our internal UUID string.
658    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    /// Execute a navigate action — send the fighter to a new URL.
668    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        // Use /json/new with URL to navigate (close old tab, open new one).
679        // Actually, better to use activate + navigate via the existing tab.
680        // For simplicity, we'll close the old tab and create a new one at the URL.
681        self.close_tab(&cdp_session.target_id).await?;
682
683        let target = self.create_tab(Some(url)).await?;
684
685        // Update our session tracking.
686        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    /// Execute a screenshot action.
712    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        // Without WebSocket, we can't send CDP commands like Page.captureScreenshot
721        // directly. Return a descriptive result indicating the command that would
722        // be sent. In a full implementation, this would use a WS connection.
723        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    /// Execute a JavaScript evaluation.
739    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    /// Execute a click action.
763    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    /// Execute a type-text action.
787    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    /// Execute a get-content action.
812    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    /// Execute a get-HTML action.
835    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    /// Execute a wait-for-selector action.
858    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    /// Shut down — kill all sessions and the Chrome process.
884    pub async fn shutdown(&self) -> PunchResult<()> {
885        info!("shutting down CDP browser driver");
886
887        // Close all tracked tabs.
888        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        // Kill Chrome process if we launched it.
901        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// ---------------------------------------------------------------------------
915// BrowserDriver trait implementation
916// ---------------------------------------------------------------------------
917
918#[async_trait]
919impl BrowserDriver for CdpBrowserDriver {
920    async fn launch(&self, config: &BrowserConfig) -> PunchResult<BrowserSession> {
921        // Update our config from the BrowserConfig.
922        let mut port_lock = self.active_port.lock().await;
923        *port_lock = Some(config.remote_debugging_port);
924        drop(port_lock);
925
926        // Launch Chrome.
927        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        // Wait for CDP to be ready.
933        self.wait_for_cdp_ready().await?;
934
935        // Create the initial tab/session.
936        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// ---------------------------------------------------------------------------
1016// Tests
1017// ---------------------------------------------------------------------------
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use uuid::Uuid;
1023
1024    // -- CdpConfig tests --
1025
1026    #[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    // -- Chrome path detection tests --
1088
1089    #[test]
1090    fn test_chrome_candidate_paths_not_empty() {
1091        let paths = chrome_candidate_paths();
1092        // On any supported platform, we should have candidate paths.
1093        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        // We can't guarantee Chrome is installed, but the function should not panic.
1102        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        // If None, that's fine — Chrome just isn't installed.
1111    }
1112
1113    // -- CdpSession tests --
1114
1115    #[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    // -- CDP command JSON formatting tests --
1147
1148    #[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    // -- CdpCommand general tests --
1245
1246    #[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    // -- CdpTargetInfo parse test --
1278
1279    #[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    // -- CdpError conversion test --
1299
1300    #[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        // Verify all error variants can be constructed and formatted.
1314        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    // -- BrowserDriver trait implementation compile-time verification --
1346
1347    #[test]
1348    fn test_cdp_browser_driver_implements_trait() {
1349        // This test verifies at compile time that CdpBrowserDriver implements
1350        // the BrowserDriver trait. If it compiles, the trait is satisfied.
1351        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    // -- Driver construction tests --
1362
1363    #[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        // Should still have the port and about:blank.
1415        assert!(args
1416            .iter()
1417            .any(|a| a.starts_with("--remote-debugging-port=")));
1418        assert!(args.contains(&"about:blank".to_string()));
1419    }
1420
1421    // -- Session lifecycle tests (no actual Chrome) --
1422
1423    #[test]
1424    fn test_cdp_driver_session_tracking() {
1425        let driver = CdpBrowserDriver::with_defaults();
1426
1427        // Manually insert a session to simulate creation.
1428        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        // Remove one.
1474        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    // -- Chrome resolve path test --
1493
1494    #[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}