use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
use crate::Capabilities;
use super::error::ManagerError;
use super::status::{Emitter, Status};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BrowserKind {
Chrome,
Firefox,
Edge,
Safari,
}
impl BrowserKind {
pub(crate) fn driver_binary_stem(self) -> &'static str {
match self {
BrowserKind::Chrome => "chromedriver",
BrowserKind::Firefox => "geckodriver",
BrowserKind::Edge => "msedgedriver",
BrowserKind::Safari => "safaridriver",
}
}
pub(crate) fn display_name(self) -> &'static str {
match self {
BrowserKind::Chrome => "Chrome",
BrowserKind::Firefox => "Firefox",
BrowserKind::Edge => "Microsoft Edge",
BrowserKind::Safari => "Safari",
}
}
pub(crate) fn cache_dir_name(self) -> &'static str {
match self {
BrowserKind::Chrome => "chromedriver",
BrowserKind::Firefox => "geckodriver",
BrowserKind::Edge => "msedgedriver",
BrowserKind::Safari => "safaridriver",
}
}
pub(crate) fn is_system_managed(self) -> bool {
matches!(self, BrowserKind::Safari)
}
pub fn from_capabilities(caps: &Capabilities) -> Result<Self, ManagerError> {
let name = caps
.get("browserName")
.and_then(Value::as_str)
.ok_or(ManagerError::MissingBrowserName)?;
match name.to_ascii_lowercase().as_str() {
"chrome" | "chromium" => Ok(BrowserKind::Chrome),
"firefox" => Ok(BrowserKind::Firefox),
"microsoftedge" | "msedge" | "edge" => Ok(BrowserKind::Edge),
"safari" => Ok(BrowserKind::Safari),
other => Err(ManagerError::UnsupportedBrowser(other.to_string())),
}
}
pub(crate) fn binary_from_caps(self, caps: &Capabilities) -> Option<String> {
let (key, sub) = match self {
BrowserKind::Chrome => ("goog:chromeOptions", "binary"),
BrowserKind::Firefox => ("moz:firefoxOptions", "binary"),
BrowserKind::Edge => ("ms:edgeOptions", "binary"),
BrowserKind::Safari => return None,
};
caps.get(key)?.get(sub)?.as_str().map(str::to_owned)
}
}
pub(crate) fn detect_local_version(
browser: BrowserKind,
caps_binary: Option<&str>,
emitter: &Emitter,
) -> Result<String, ManagerError> {
if browser.is_system_managed() {
return Ok("system".to_string());
}
if let Some(path) = caps_binary {
if let Some(version) = run_version(path, browser) {
emitter.emit(Status::LocalBrowserDetected {
browser,
version: version.clone(),
binary: PathBuf::from(path),
});
return Ok(version);
}
return Err(ManagerError::LocalBrowserNotFound {
browser: browser.display_name(),
hint: "the binary path in capabilities did not respond to --version; \
try DriverVersion::Latest or DriverVersion::Exact(...)",
});
}
for candidate in candidate_paths(browser) {
if let Some(v) = run_version(&candidate, browser) {
emitter.emit(Status::LocalBrowserDetected {
browser,
version: v.clone(),
binary: PathBuf::from(&candidate),
});
return Ok(v);
}
}
Err(ManagerError::LocalBrowserNotFound {
browser: browser.display_name(),
hint: "no installed copy was found; try DriverVersion::Latest or DriverVersion::Exact(...)",
})
}
fn candidate_paths(browser: BrowserKind) -> Vec<String> {
#[cfg(target_os = "macos")]
{
match browser {
BrowserKind::Chrome => vec![
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string(),
"/Applications/Chromium.app/Contents/MacOS/Chromium".to_string(),
"google-chrome".to_string(),
"chromium".to_string(),
],
BrowserKind::Firefox => vec![
"/Applications/Firefox.app/Contents/MacOS/firefox".to_string(),
"firefox".to_string(),
],
BrowserKind::Edge => {
vec!["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge".to_string()]
}
BrowserKind::Safari => Vec::new(),
}
}
#[cfg(target_os = "linux")]
{
match browser {
BrowserKind::Chrome => vec![
"google-chrome".to_string(),
"google-chrome-stable".to_string(),
"chromium".to_string(),
"chromium-browser".to_string(),
],
BrowserKind::Firefox => vec!["firefox".to_string(), "firefox-esr".to_string()],
BrowserKind::Edge => {
vec!["microsoft-edge".to_string(), "microsoft-edge-stable".to_string()]
}
BrowserKind::Safari => Vec::new(),
}
}
#[cfg(target_os = "windows")]
{
match browser {
BrowserKind::Chrome => vec![
r"C:\Program Files\Google\Chrome\Application\chrome.exe".to_string(),
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe".to_string(),
"chrome.exe".to_string(),
],
BrowserKind::Firefox => vec![
r"C:\Program Files\Mozilla Firefox\firefox.exe".to_string(),
r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe".to_string(),
"firefox.exe".to_string(),
],
BrowserKind::Edge => vec![
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe".to_string(),
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe".to_string(),
"msedge.exe".to_string(),
],
BrowserKind::Safari => Vec::new(),
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = browser;
Vec::new()
}
}
fn run_version(path: &str, browser: BrowserKind) -> Option<String> {
if !exists_or_in_path(path) {
return None;
}
#[cfg(target_os = "windows")]
{
let _ = browser;
return read_pe_version(path);
}
#[cfg(not(target_os = "windows"))]
{
let _ = browser;
let output = Command::new(path).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
parse_version(&String::from_utf8_lossy(&output.stdout))
}
}
#[cfg(target_os = "windows")]
fn read_pe_version(path: &str) -> Option<String> {
let abs = if Path::new(path).is_absolute() {
path.to_string()
} else {
let out = Command::new("where").arg(path).output().ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).lines().next()?.trim().to_string()
};
let escaped = abs.replace('\'', "''");
let script = format!("(Get-Item -LiteralPath '{escaped}').VersionInfo.ProductVersion");
let output = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_version(&String::from_utf8_lossy(&output.stdout))
}
fn exists_or_in_path(path: &str) -> bool {
let p = Path::new(path);
if p.is_absolute() {
return p.exists();
}
true
}
pub(crate) fn parse_version(s: &str) -> Option<String> {
let mut it = s.chars().peekable();
while it.peek().is_some() {
if !it.peek().is_some_and(|c| c.is_ascii_digit()) {
it.next();
continue;
}
let mut buf = String::new();
while let Some(&c) = it.peek() {
if c.is_ascii_digit() || c == '.' {
buf.push(c);
it.next();
} else {
break;
}
}
if buf.contains('.') && buf.chars().next().is_some_and(|c| c.is_ascii_digit()) {
let trimmed = buf.trim_end_matches('.');
return Some(trimmed.to_string());
}
}
None
}
pub(crate) fn major(version: &str) -> &str {
version.split('.').next().unwrap_or(version)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_chrome_version() {
assert_eq!(
parse_version("Google Chrome 126.0.6478.126 \n").as_deref(),
Some("126.0.6478.126")
);
}
#[test]
fn parse_firefox_version() {
assert_eq!(parse_version("Mozilla Firefox 128.0.2").as_deref(), Some("128.0.2"));
}
#[test]
fn major_strips() {
assert_eq!(major("126.0.6478.126"), "126");
assert_eq!(major("128"), "128");
}
#[test]
fn browser_kind_chrome() {
let mut caps = Capabilities::new();
caps.set("browserName", "chrome").unwrap();
assert_eq!(BrowserKind::from_capabilities(&caps).unwrap(), BrowserKind::Chrome);
}
#[test]
fn browser_kind_chromium() {
let mut caps = Capabilities::new();
caps.set("browserName", "chromium").unwrap();
assert_eq!(BrowserKind::from_capabilities(&caps).unwrap(), BrowserKind::Chrome);
}
#[test]
fn browser_kind_firefox() {
let mut caps = Capabilities::new();
caps.set("browserName", "firefox").unwrap();
assert_eq!(BrowserKind::from_capabilities(&caps).unwrap(), BrowserKind::Firefox);
}
#[test]
fn browser_kind_edge() {
for name in ["microsoftedge", "MicrosoftEdge", "edge", "msedge"] {
let mut caps = Capabilities::new();
caps.set("browserName", name).unwrap();
assert_eq!(
BrowserKind::from_capabilities(&caps).unwrap(),
BrowserKind::Edge,
"browserName={name}"
);
}
}
#[test]
fn browser_kind_safari() {
let mut caps = Capabilities::new();
caps.set("browserName", "safari").unwrap();
assert_eq!(BrowserKind::from_capabilities(&caps).unwrap(), BrowserKind::Safari);
}
#[test]
fn browser_kind_unsupported() {
let mut caps = Capabilities::new();
caps.set("browserName", "ie").unwrap();
assert!(matches!(
BrowserKind::from_capabilities(&caps),
Err(ManagerError::UnsupportedBrowser(_))
));
}
#[test]
fn safari_is_system_managed() {
assert!(BrowserKind::Safari.is_system_managed());
assert!(!BrowserKind::Chrome.is_system_managed());
assert!(!BrowserKind::Firefox.is_system_managed());
assert!(!BrowserKind::Edge.is_system_managed());
}
#[test]
fn binary_from_caps_edge() {
let mut caps = Capabilities::new();
caps.set("ms:edgeOptions", json!({"binary": "/path/to/msedge"})).unwrap();
assert_eq!(BrowserKind::Edge.binary_from_caps(&caps).as_deref(), Some("/path/to/msedge"));
}
#[test]
fn browser_kind_missing() {
let caps = Capabilities::new();
assert!(matches!(
BrowserKind::from_capabilities(&caps),
Err(ManagerError::MissingBrowserName)
));
}
#[test]
fn binary_from_caps_chrome() {
let mut caps = Capabilities::new();
caps.set("goog:chromeOptions", json!({"binary": "/path/to/chrome"})).unwrap();
assert_eq!(BrowserKind::Chrome.binary_from_caps(&caps).as_deref(), Some("/path/to/chrome"));
}
#[test]
fn binary_from_caps_firefox() {
let mut caps = Capabilities::new();
caps.set("moz:firefoxOptions", json!({"binary": "/path/to/firefox"})).unwrap();
assert_eq!(
BrowserKind::Firefox.binary_from_caps(&caps).as_deref(),
Some("/path/to/firefox")
);
}
}