#[cfg(feature = "cdp")]
use std::io::{BufRead, BufReader};
#[cfg(feature = "cdp")]
use std::process::{Child, Command, Stdio};
#[cfg(feature = "cdp")]
use std::sync::Arc;
use std::time::Duration;
#[cfg(feature = "cdp")]
use parking_lot::RwLock;
use super::detect::ChromeError;
#[cfg(feature = "cdp")]
use super::detect::{find_chrome, free_port};
#[cfg(feature = "cdp")]
use crate::cdp_client::cdp::CdpClient;
#[cfg(feature = "cdp")]
pub use crate::cdp_page::CdpPage;
const DEVTOOLS_LISTENING_PREFIX: &str = "DevTools listening on ";
#[derive(Debug, Clone)]
pub struct BrowserConfig {
pub headless: bool,
pub timeout: Duration,
pub viewport: (u32, u32),
pub args: Vec<String>,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
headless: true,
timeout: Duration::from_secs(30),
viewport: (1280, 720),
args: Vec::new(),
}
}
}
#[cfg(feature = "cdp")]
pub struct Browser {
cdp: Arc<CdpClient>,
pages: Arc<RwLock<Vec<CdpPage>>>,
child: Option<Child>,
}
#[cfg(feature = "cdp")]
impl std::fmt::Debug for Browser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Browser")
.field("cdp", &"<CdpClient>")
.field("pages", &self.pages.read().len())
.field("child_pid", &self.child.as_ref().map(Child::id))
.finish()
}
}
#[cfg(feature = "cdp")]
impl Browser {
pub fn cdp(&self) -> &Arc<CdpClient> {
&self.cdp
}
pub async fn connect(ws_url: &str) -> Result<Self, BrowserError> {
let cdp = CdpClient::connect(ws_url)
.await
.map_err(|e| BrowserError::CdpConnectionFailed(e.to_string()))?;
let cdp = Arc::new(cdp);
cdp.send_command(
"Target.setDiscoverTargets",
Some(serde_json::json!({"discover": true})),
)
.await
.map_err(|e| BrowserError::CdpConnectionFailed(e.to_string()))?;
cdp.send_command(
"Target.setAutoAttach",
Some(serde_json::json!({
"autoAttach": true,
"flatten": true
})),
)
.await
.map_err(|e| BrowserError::CdpConnectionFailed(e.to_string()))?;
Ok(Browser {
cdp,
pages: Arc::new(RwLock::new(Vec::new())),
child: None,
})
}
pub async fn new_page(&self) -> Result<CdpPage, BrowserError> {
let result = self
.cdp
.send_command(
"Target.createTarget",
Some(serde_json::json!({"url": "about:blank"})),
)
.await
.map_err(|e| BrowserError::CdpConnectionFailed(e.to_string()))?;
let target_id = result
.result
.as_ref()
.and_then(|r| r["targetId"].as_str())
.ok_or_else(|| {
BrowserError::CdpConnectionFailed("missing targetId in response".to_string())
})?
.to_string();
let attach_result = self
.cdp
.send_command(
"Target.attachToTarget",
Some(serde_json::json!({
"targetId": target_id,
"flatten": true
})),
)
.await
.map_err(|e| BrowserError::CdpConnectionFailed(e.to_string()))?;
let session_id = attach_result
.result
.as_ref()
.and_then(|r| r["sessionId"].as_str())
.ok_or_else(|| {
BrowserError::CdpConnectionFailed(
"missing sessionId in attach response".to_string(),
)
})?
.to_string();
let page = CdpPage::new(target_id, session_id, self.cdp.clone());
self.pages.write().push(page.clone());
Ok(page)
}
#[must_use]
pub fn pages(&self) -> Vec<CdpPage> {
self.pages.read().clone()
}
}
#[cfg(feature = "cdp")]
impl Drop for Browser {
fn drop(&mut self) {
if let Some(ref mut child) = self.child {
tracing::info!("killing Chrome process (pid: {})", child.id());
let _ = child.kill();
let _ = child.wait();
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum BrowserError {
#[error("Chrome binary not found: {0}")]
ChromeNotFound(#[from] ChromeError),
#[error("failed to spawn Chrome: {0}")]
SpawnFailed(#[from] std::io::Error),
#[error("Chrome did not emit DevTools URL within timeout")]
DevToolsUrlTimeout,
#[error("DevTools URL channel closed (Chrome exited prematurely)")]
DevToolsUrlChannelClosed,
#[error("failed to connect to Chrome CDP: {0}")]
CdpConnectionFailed(String),
}
#[cfg(feature = "cdp")]
pub async fn launch_chrome(config: BrowserConfig) -> Result<Browser, BrowserError> {
let chrome_path = find_chrome()?;
let port = free_port()?;
let mut args = vec![
format!("--remote-debugging-port={port}"),
"--no-sandbox".to_string(),
"--disable-gpu".to_string(),
"--disable-dev-shm-usage".to_string(),
format!("--window-size={},{}", config.viewport.0, config.viewport.1),
];
if config.headless {
args.push("--headless=new".to_string());
}
args.extend(config.args);
let mut child = Command::new(&chrome_path)
.args(&args)
.stderr(Stdio::piped())
.stdout(Stdio::null())
.stdin(Stdio::null())
.spawn()?;
let stderr = child.stderr.take().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::Other, "failed to capture stderr")
})?;
let (url_tx, url_rx) = tokio::sync::oneshot::channel();
tokio::task::spawn_blocking(move || {
let reader = BufReader::new(stderr);
let mut url_tx = Some(url_tx);
for line in reader.lines().map_while(Result::ok) {
if let Some(tx) = url_tx.take() {
if let Some(ws) = line.strip_prefix(DEVTOOLS_LISTENING_PREFIX) {
tracing::info!("found DevTools URL: {ws}");
let _ = tx.send(ws.to_string());
continue;
}
url_tx = Some(tx);
}
tracing::trace!("chrome stderr: {line}");
}
});
let ws_url = tokio::time::timeout(config.timeout, url_rx)
.await
.map_err(|_| BrowserError::DevToolsUrlTimeout)?
.map_err(|_| BrowserError::DevToolsUrlChannelClosed)?;
let cdp = CdpClient::connect(&ws_url)
.await
.map_err(|e| BrowserError::CdpConnectionFailed(e.to_string()))?;
let cdp = Arc::new(cdp);
cdp.send_command(
"Target.setAutoAttach",
Some(serde_json::json!({
"autoAttach": true,
"flatten": true
})),
)
.await
.map_err(|e| BrowserError::CdpConnectionFailed(e.to_string()))?;
Ok(Browser {
cdp,
pages: Arc::new(RwLock::new(Vec::new())),
child: Some(child),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn browser_config_defaults() {
let config = BrowserConfig::default();
assert!(config.headless);
assert_eq!(config.timeout, Duration::from_secs(30));
assert_eq!(config.viewport, (1280, 720));
assert!(config.args.is_empty());
}
#[test]
fn browser_config_custom() {
let config = BrowserConfig {
headless: false,
timeout: Duration::from_secs(60),
viewport: (1920, 1080),
args: vec!["--disable-web-security".to_string()],
};
assert!(!config.headless);
assert_eq!(config.timeout, Duration::from_secs(60));
assert_eq!(config.viewport, (1920, 1080));
assert_eq!(config.args.len(), 1);
}
#[cfg(not(miri))]
#[cfg(feature = "cdp")]
#[tokio::test]
async fn launch_and_kill_chrome() {
if find_chrome().is_err() {
eprintln!("Chrome not found, skipping integration test");
return;
}
let config = BrowserConfig::default();
let browser = launch_chrome(config)
.await
.expect("launch_chrome should succeed");
let pid = browser.child.as_ref().expect("child should exist").id();
assert!(pid > 0, "Chrome PID should be greater than 0");
assert!(is_process_running(pid), "Chrome process should be running");
drop(browser);
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(
!is_process_running(pid),
"Chrome process should be killed after dropping Browser"
);
}
#[cfg(all(unix, feature = "cdp"))]
fn is_process_running(pid: u32) -> bool {
std::process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(not(all(unix, feature = "cdp")))]
fn is_process_running(_pid: u32) -> bool {
true
}
#[cfg(not(miri))]
#[cfg(feature = "cdp")]
#[tokio::test]
async fn connect_and_new_page() {
if find_chrome().is_err() {
eprintln!("Chrome not found, skipping integration test");
return;
}
let config = BrowserConfig::default();
let browser = launch_chrome(config)
.await
.expect("launch_chrome should succeed");
let page = browser.new_page().await.expect("new_page should succeed");
assert!(!page.target_id.is_empty(), "target_id should not be empty");
assert!(
!page.session_id.is_empty(),
"session_id should not be empty"
);
let pages = browser.pages();
assert_eq!(pages.len(), 1, "should have 1 page");
assert_eq!(pages[0].target_id, page.target_id);
let page2 = browser.new_page().await.expect("new_page should succeed");
assert!(!page2.target_id.is_empty());
assert_ne!(page.target_id, page2.target_id, "target IDs should differ");
let pages = browser.pages();
assert_eq!(pages.len(), 2, "should have 2 pages");
}
#[cfg(not(miri))]
#[cfg(feature = "cdp")]
#[tokio::test]
async fn connect_to_existing_browser() {
if find_chrome().is_err() {
eprintln!("Chrome not found, skipping integration test");
return;
}
let config = BrowserConfig::default();
let browser1 = launch_chrome(config)
.await
.expect("launch_chrome should succeed");
let page = browser1.new_page().await.expect("new_page should succeed");
assert!(!page.target_id.is_empty());
assert!(!page.session_id.is_empty());
assert_eq!(browser1.pages().len(), 1);
drop(browser1);
tokio::time::sleep(Duration::from_millis(500)).await;
}
}