use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use super::ChromeError;
use super::discovery::query_version;
pub struct LaunchConfig {
pub executable: PathBuf,
pub port: u16,
pub headless: bool,
pub extra_args: Vec<String>,
pub user_data_dir: Option<PathBuf>,
}
pub struct ChromeProcess {
child: Option<std::process::Child>,
port: u16,
temp_dir: Option<TempDir>,
}
struct TempDir {
path: PathBuf,
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
impl ChromeProcess {
#[must_use]
pub fn pid(&self) -> u32 {
self.child.as_ref().map_or(0, std::process::Child::id)
}
#[must_use]
#[allow(dead_code)]
pub fn port(&self) -> u16 {
self.port
}
pub fn kill(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
#[must_use]
pub fn detach(mut self) -> (u32, u16) {
let pid = self.pid();
let port = self.port;
self.child = None;
self.temp_dir = None;
(pid, port)
}
}
impl Drop for ChromeProcess {
fn drop(&mut self) {
self.kill();
}
}
fn random_suffix() -> String {
use std::io::Read;
let mut buf = [0u8; 8];
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
if f.read_exact(&mut buf).is_ok() {
return hex_encode(&buf);
}
}
let pid = std::process::id();
let addr = &raw const buf as usize;
format!("{pid:x}-{addr:x}")
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
pub fn find_available_port() -> Result<u16, ChromeError> {
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| {
ChromeError::LaunchFailed(format!("could not bind to find a free port: {e}"))
})?;
let port = listener
.local_addr()
.map_err(|e| ChromeError::LaunchFailed(format!("could not get local address: {e}")))?
.port();
drop(listener);
Ok(port)
}
fn build_chrome_args(config: &LaunchConfig, data_dir: &Path) -> Vec<String> {
let mut args = vec![
format!("--remote-debugging-port={}", config.port),
format!("--user-data-dir={}", data_dir.display()),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
"--enable-automation".to_string(),
];
if config.headless {
args.push("--headless=new".to_string());
}
for arg in &config.extra_args {
args.push(arg.clone());
}
args
}
pub async fn launch_chrome(
config: LaunchConfig,
timeout: Duration,
) -> Result<ChromeProcess, ChromeError> {
let (data_dir, temp_dir) = if let Some(ref dir) = config.user_data_dir {
(dir.clone(), None)
} else {
let dir = std::env::temp_dir().join(format!("chrome-cli-{}", random_suffix()));
std::fs::create_dir_all(&dir)?;
let td = TempDir { path: dir.clone() };
(dir, Some(td))
};
let args = build_chrome_args(&config, &data_dir);
let mut cmd = Command::new(&config.executable);
for arg in &args {
cmd.arg(arg);
}
cmd.stdout(Stdio::null()).stderr(Stdio::null());
let child = cmd.spawn().map_err(|e| {
ChromeError::LaunchFailed(format!(
"failed to spawn {}: {e}",
config.executable.display()
))
})?;
let mut process = ChromeProcess {
child: Some(child),
port: config.port,
temp_dir,
};
let start = tokio::time::Instant::now();
let poll_interval = Duration::from_millis(100);
loop {
if start.elapsed() > timeout {
process.kill();
return Err(ChromeError::StartupTimeout { port: config.port });
}
if let Some(child) = process.child.as_mut() {
if let Ok(Some(status)) = child.try_wait() {
return Err(ChromeError::LaunchFailed(format!(
"Chrome exited with status {status} before becoming ready"
)));
}
}
if query_version("127.0.0.1", config.port).await.is_ok() {
return Ok(process);
}
tokio::time::sleep(poll_interval).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_available_port_returns_valid_port() {
let port = find_available_port().unwrap();
assert!(port > 0, "Expected a positive port number, got {port}");
}
fn default_launch_config(port: u16) -> LaunchConfig {
LaunchConfig {
executable: PathBuf::from("/usr/bin/chrome"),
port,
headless: false,
extra_args: vec![],
user_data_dir: None,
}
}
#[test]
fn automation_flag_is_included_on_launch() {
let config = default_launch_config(9222);
let data_dir = PathBuf::from("/tmp/test-data");
let args = build_chrome_args(&config, &data_dir);
assert!(
args.iter().any(|a| a == "--enable-automation"),
"Expected --enable-automation in args: {args:?}"
);
}
#[test]
fn headless_mode_includes_automation_flag() {
let mut config = default_launch_config(9222);
config.headless = true;
let data_dir = PathBuf::from("/tmp/test-data");
let args = build_chrome_args(&config, &data_dir);
assert!(
args.iter().any(|a| a == "--enable-automation"),
"Expected --enable-automation in args: {args:?}"
);
assert!(
args.iter().any(|a| a == "--headless=new"),
"Expected --headless=new in args: {args:?}"
);
}
#[test]
fn extra_args_do_not_conflict_with_automation_flag() {
let mut config = default_launch_config(9222);
config.extra_args = vec!["--enable-automation".to_string()];
let data_dir = PathBuf::from("/tmp/test-data");
let args = build_chrome_args(&config, &data_dir);
assert!(
args.iter().any(|a| a == "--enable-automation"),
"Expected --enable-automation in args: {args:?}"
);
}
#[test]
fn temp_dir_cleanup_on_drop() {
let path = std::env::temp_dir().join("chrome-cli-test-cleanup");
std::fs::create_dir_all(&path).unwrap();
assert!(path.exists());
let td = TempDir { path: path.clone() };
drop(td);
assert!(!path.exists(), "TempDir should have been cleaned up");
}
}