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::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// ---------------------------------------------------------------------------
29// CDP-specific configuration
30// ---------------------------------------------------------------------------
31
32/// Configuration specific to the CDP browser driver.
33///
34/// Extends `BrowserConfig` with CDP-specific knobs — fine-tuning the
35/// fighter's gloves before the bout.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CdpConfig {
38    /// Path to the Chrome/Chromium binary. `None` means auto-detect.
39    pub chrome_path: Option<String>,
40    /// Remote debugging port. Default: `9222`.
41    pub debug_port: u16,
42    /// Run headless (no visible window). Default: `true`.
43    pub headless: bool,
44    /// Custom user-data directory. `None` uses a temp directory.
45    pub user_data_dir: Option<String>,
46    /// Additional Chrome launch arguments.
47    pub extra_args: Vec<String>,
48    /// Connection timeout in seconds. Default: `10`.
49    pub connect_timeout_secs: u64,
50    /// Whether to disable GPU acceleration. Default: `true`.
51    pub disable_gpu: bool,
52    /// Whether to run with `--no-sandbox`. Default: `true`.
53    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// ---------------------------------------------------------------------------
84// CDP errors
85// ---------------------------------------------------------------------------
86
87/// CDP-specific errors — when the fighter takes a hit in the browser ring.
88#[derive(Debug, thiserror::Error)]
89pub enum CdpError {
90    /// Chrome binary not found on the system.
91    #[error("Chrome binary not found; searched: {searched_paths:?}")]
92    ChromeNotFound { searched_paths: Vec<String> },
93
94    /// Failed to launch the Chrome process.
95    #[error("failed to launch Chrome: {reason}")]
96    LaunchFailed { reason: String },
97
98    /// Could not connect to the CDP debug endpoint.
99    #[error("failed to connect to CDP on port {port}: {reason}")]
100    ConnectionFailed { port: u16, reason: String },
101
102    /// A CDP command returned an error.
103    #[error("CDP command error (id={command_id}): {message}")]
104    CommandError { command_id: u64, message: String },
105
106    /// Session not found in the driver's tracking map.
107    #[error("CDP session not found: {session_id}")]
108    SessionNotFound { session_id: String },
109
110    /// The CDP endpoint returned an unexpected response.
111    #[error("unexpected CDP response: {detail}")]
112    UnexpectedResponse { detail: String },
113
114    /// Timeout waiting for a CDP operation.
115    #[error("CDP operation timed out after {timeout_secs}s")]
116    Timeout { timeout_secs: u64 },
117
118    /// HTTP request to the CDP endpoint failed.
119    #[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// ---------------------------------------------------------------------------
133// CDP session tracking
134// ---------------------------------------------------------------------------
135
136/// Internal tracking data for a CDP tab/target — the fighter's corner intel.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct CdpSession {
139    /// Our internal session UUID (matches `BrowserSession.id`).
140    pub id: String,
141    /// The CDP target ID returned by Chrome.
142    pub target_id: String,
143    /// The WebSocket debugger URL for this target.
144    pub ws_url: String,
145    /// When this CDP session was created.
146    pub created_at: chrono::DateTime<chrono::Utc>,
147}
148
149/// Response from `GET /json/new` and `GET /json/list`.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct CdpTargetInfo {
153    /// Target description (usually empty for pages).
154    #[serde(default)]
155    pub description: String,
156    /// DevTools frontend URL.
157    #[serde(default)]
158    pub devtools_frontend_url: String,
159    /// Unique target identifier.
160    pub id: String,
161    /// Page title.
162    #[serde(default)]
163    pub title: String,
164    /// Target type (e.g. "page", "background_page").
165    #[serde(default, rename = "type")]
166    pub target_type: String,
167    /// Current URL loaded in the target.
168    #[serde(default)]
169    pub url: String,
170    /// WebSocket debugger URL for direct CDP communication.
171    #[serde(default)]
172    pub web_socket_debugger_url: String,
173}
174
175// ---------------------------------------------------------------------------
176// CDP command / response structures
177// ---------------------------------------------------------------------------
178
179/// A CDP JSON-RPC command — the punch being thrown.
180#[derive(Debug, Clone, Serialize)]
181pub struct CdpCommand {
182    /// Monotonically increasing command ID.
183    pub id: u64,
184    /// CDP method name (e.g. "Page.navigate", "Runtime.evaluate").
185    pub method: String,
186    /// Method parameters.
187    pub params: serde_json::Value,
188}
189
190impl CdpCommand {
191    /// Create a new CDP command with the given method and params.
192    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    /// Serialize to JSON string.
201    pub fn to_json(&self) -> PunchResult<String> {
202        serde_json::to_string(self).map_err(PunchError::from)
203    }
204}
205
206/// A CDP JSON-RPC response.
207#[derive(Debug, Clone, Deserialize)]
208pub struct CdpResponse {
209    /// The command ID this is responding to.
210    pub id: Option<u64>,
211    /// The result payload (present on success).
212    pub result: Option<serde_json::Value>,
213    /// Error information (present on failure).
214    pub error: Option<CdpResponseError>,
215}
216
217/// Error payload within a CDP response.
218#[derive(Debug, Clone, Deserialize)]
219pub struct CdpResponseError {
220    /// Error code.
221    pub code: i64,
222    /// Human-readable error message.
223    pub message: String,
224    /// Additional error data.
225    pub data: Option<serde_json::Value>,
226}
227
228// ---------------------------------------------------------------------------
229// Chrome path detection
230// ---------------------------------------------------------------------------
231
232/// Attempt to find a Chrome or Chromium binary on the current system.
233///
234/// Checks well-known installation paths on macOS, Linux, and Windows.
235/// Returns the first path that exists, or `None` if Chrome is not found.
236pub 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
248/// Return the list of candidate Chrome/Chromium paths for the current platform.
249pub fn chrome_candidate_paths() -> Vec<String> {
250    let mut paths = Vec::new();
251
252    // macOS paths
253    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    // Linux paths
264    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    // Windows paths
276    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
287// ---------------------------------------------------------------------------
288// CDP command builders
289// ---------------------------------------------------------------------------
290
291/// Build a `Page.navigate` CDP command.
292pub fn build_navigate_command(id: u64, url: &str) -> CdpCommand {
293    CdpCommand::new(id, "Page.navigate", serde_json::json!({ "url": url }))
294}
295
296/// Build a `Page.captureScreenshot` CDP command.
297pub 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
306/// Build a `Runtime.evaluate` CDP command.
307pub 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
319/// Build a `Runtime.evaluate` command for clicking an element by selector.
320pub 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
333/// Build a `Runtime.evaluate` command for getting element text content.
334pub 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
352/// Build a `Runtime.evaluate` command for getting element HTML.
353pub 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
371/// Build a `Runtime.evaluate` command for typing text into an element.
372pub 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
391/// Build a `Runtime.evaluate` command for waiting for a selector.
392pub 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
413// ---------------------------------------------------------------------------
414// CDP Browser Driver
415// ---------------------------------------------------------------------------
416
417/// A real CDP browser driver that communicates with Chrome/Chromium.
418///
419/// This is the heavyweight champion — it launches Chrome, manages tabs
420/// through the `/json/*` HTTP endpoints, and executes CDP commands to
421/// drive browser automation.
422pub struct CdpBrowserDriver {
423    /// HTTP client for CDP management endpoints.
424    client: reqwest::Client,
425    /// Active CDP sessions, keyed by our internal session UUID string.
426    sessions: DashMap<String, CdpSession>,
427    /// CDP configuration.
428    config: CdpConfig,
429    /// Monotonically increasing command ID counter.
430    command_counter: AtomicU64,
431    /// The Chrome child process, if we launched it.
432    chrome_process: Arc<Mutex<Option<Child>>>,
433    /// The debug port actually in use (may differ from config if auto-assigned).
434    active_port: Arc<Mutex<Option<u16>>>,
435}
436
437impl CdpBrowserDriver {
438    /// Create a new CDP driver with the given configuration.
439    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    /// Create a driver with default configuration.
451    pub fn with_defaults() -> Self {
452        Self::new(CdpConfig::default())
453    }
454
455    /// Get the next command ID.
456    fn next_id(&self) -> u64 {
457        self.command_counter.fetch_add(1, Ordering::Relaxed)
458    }
459
460    /// Get the debug port (active or configured).
461    async fn debug_port(&self) -> u16 {
462        self.active_port
463            .lock()
464            .await
465            .unwrap_or(self.config.debug_port)
466    }
467
468    /// Build the base URL for CDP HTTP management endpoints.
469    async fn base_url(&self) -> String {
470        format!("http://localhost:{}", self.debug_port().await)
471    }
472
473    /// Resolve the Chrome binary path (from config or auto-detect).
474    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    /// Build Chrome launch arguments.
484    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        // Start with about:blank to avoid loading any default page.
507        args.push("about:blank".to_string());
508
509        args
510    }
511
512    /// Launch Chrome as a child process.
513    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    /// Wait for Chrome's CDP endpoint to become available.
534    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    /// Create a new tab via the CDP `/json/new` endpoint.
565    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    /// List all open tabs/targets via `/json/list`.
597    #[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    /// Close a tab via `/json/close/{targetId}`.
620    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    /// Activate (bring to front) a tab via `/json/activate/{targetId}`.
639    #[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    /// Look up a CDP session by our internal UUID string.
654    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    /// Execute a navigate action — send the fighter to a new URL.
664    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        // Use /json/new with URL to navigate (close old tab, open new one).
675        // Actually, better to use activate + navigate via the existing tab.
676        // For simplicity, we'll close the old tab and create a new one at the URL.
677        self.close_tab(&cdp_session.target_id).await?;
678
679        let target = self.create_tab(Some(url)).await?;
680
681        // Update our session tracking.
682        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    /// Execute a screenshot action.
708    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        // Without WebSocket, we can't send CDP commands like Page.captureScreenshot
717        // directly. Return a descriptive result indicating the command that would
718        // be sent. In a full implementation, this would use a WS connection.
719        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    /// Execute a JavaScript evaluation.
735    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    /// Execute a click action.
759    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    /// Execute a type-text action.
783    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    /// Execute a get-content action.
808    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    /// Execute a get-HTML action.
831    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    /// Execute a wait-for-selector action.
854    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    /// Shut down — kill all sessions and the Chrome process.
880    pub async fn shutdown(&self) -> PunchResult<()> {
881        info!("shutting down CDP browser driver");
882
883        // Close all tracked tabs.
884        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        // Kill Chrome process if we launched it.
897        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// ---------------------------------------------------------------------------
911// BrowserDriver trait implementation
912// ---------------------------------------------------------------------------
913
914#[async_trait]
915impl BrowserDriver for CdpBrowserDriver {
916    async fn launch(&self, config: &BrowserConfig) -> PunchResult<BrowserSession> {
917        // Update our config from the BrowserConfig.
918        let mut port_lock = self.active_port.lock().await;
919        *port_lock = Some(config.remote_debugging_port);
920        drop(port_lock);
921
922        // Launch Chrome.
923        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        // Wait for CDP to be ready.
929        self.wait_for_cdp_ready().await?;
930
931        // Create the initial tab/session.
932        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// ---------------------------------------------------------------------------
1011// Tests
1012// ---------------------------------------------------------------------------
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use uuid::Uuid;
1018
1019    // -- CdpConfig tests --
1020
1021    #[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    // -- Chrome path detection tests --
1082
1083    #[test]
1084    fn test_chrome_candidate_paths_not_empty() {
1085        let paths = chrome_candidate_paths();
1086        // On any supported platform, we should have candidate paths.
1087        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        // We can't guarantee Chrome is installed, but the function should not panic.
1096        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        // If None, that's fine — Chrome just isn't installed.
1105    }
1106
1107    // -- CdpSession tests --
1108
1109    #[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    // -- CDP command JSON formatting tests --
1140
1141    #[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    // -- CdpCommand general tests --
1238
1239    #[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    // -- CdpTargetInfo parse test --
1270
1271    #[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    // -- CdpError conversion test --
1291
1292    #[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        // Verify all error variants can be constructed and formatted.
1306        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    // -- BrowserDriver trait implementation compile-time verification --
1338
1339    #[test]
1340    fn test_cdp_browser_driver_implements_trait() {
1341        // This test verifies at compile time that CdpBrowserDriver implements
1342        // the BrowserDriver trait. If it compiles, the trait is satisfied.
1343        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    // -- Driver construction tests --
1354
1355    #[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        // Should still have the port and about:blank.
1407        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    // -- Session lifecycle tests (no actual Chrome) --
1415
1416    #[test]
1417    fn test_cdp_driver_session_tracking() {
1418        let driver = CdpBrowserDriver::with_defaults();
1419
1420        // Manually insert a session to simulate creation.
1421        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        // Remove one.
1467        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    // -- Chrome resolve path test --
1486
1487    #[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}