use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;
use serde_json::json;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::time::timeout;
use tracing::{debug, info, warn};
use zendriver_stealth::{Persona, Seed, StealthObserver, StealthProfile, Strategy, Surface};
use zendriver_transport::{
Connection, ObserverError, PausedSession, SessionHandle, TargetObserver,
};
use crate::error::{BrowserError, ZendriverError};
use crate::input::InputController;
use crate::tab::Tab;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Channel {
Chrome,
Chromium,
Brave,
Edge,
#[default]
Auto,
}
pub fn find_chrome_executable() -> Result<PathBuf, BrowserError> {
find_chrome_executable_for_channel(Channel::Auto)
}
pub fn find_chrome_executable_for_channel(channel: Channel) -> Result<PathBuf, BrowserError> {
let candidates = candidate_paths_for_channel(channel);
for c in &candidates {
if c.exists() {
return Ok(c.clone());
}
}
Err(BrowserError::ExecutableNotFound {
searched: candidates,
})
}
pub(crate) fn candidate_paths_for_channel(channel: Channel) -> Vec<PathBuf> {
let mut v = Vec::new();
let path_names: &[&str] = match channel {
Channel::Chrome => &["google-chrome", "google-chrome-stable", "chrome"],
Channel::Chromium => &["chromium", "chromium-browser"],
Channel::Brave => &["brave-browser", "brave"],
Channel::Edge => &["microsoft-edge", "microsoft-edge-stable"],
Channel::Auto => &[
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"chrome",
],
};
for name in path_names {
if let Some(p) = which_on_path(name) {
v.push(p);
}
}
#[cfg(target_os = "macos")]
{
let chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
let chromium = "/Applications/Chromium.app/Contents/MacOS/Chromium";
let brave = "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser";
let edge = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge";
match channel {
Channel::Chrome => v.push(PathBuf::from(chrome)),
Channel::Chromium => v.push(PathBuf::from(chromium)),
Channel::Brave => v.push(PathBuf::from(brave)),
Channel::Edge => v.push(PathBuf::from(edge)),
Channel::Auto => {
v.push(PathBuf::from(chrome));
v.push(PathBuf::from(chromium));
}
}
}
#[cfg(target_os = "linux")]
{
match channel {
Channel::Chrome => {
v.push(PathBuf::from("/usr/bin/google-chrome"));
v.push(PathBuf::from("/usr/bin/google-chrome-stable"));
}
Channel::Chromium => {
v.push(PathBuf::from("/usr/bin/chromium"));
v.push(PathBuf::from("/usr/bin/chromium-browser"));
v.push(PathBuf::from("/snap/bin/chromium"));
}
Channel::Brave => {
v.push(PathBuf::from("/usr/bin/brave-browser"));
v.push(PathBuf::from("/usr/bin/brave"));
v.push(PathBuf::from("/opt/brave.com/brave/brave-browser"));
}
Channel::Edge => {
v.push(PathBuf::from("/usr/bin/microsoft-edge"));
v.push(PathBuf::from("/usr/bin/microsoft-edge-stable"));
v.push(PathBuf::from("/opt/microsoft/msedge/microsoft-edge"));
}
Channel::Auto => {
v.push(PathBuf::from("/usr/bin/google-chrome"));
v.push(PathBuf::from("/usr/bin/chromium"));
v.push(PathBuf::from("/usr/bin/chromium-browser"));
v.push(PathBuf::from("/snap/bin/chromium"));
}
}
}
#[cfg(target_os = "windows")]
{
let chrome = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
];
let brave = [
r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
r"C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe",
];
let edge = [
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
];
match channel {
Channel::Chrome | Channel::Chromium | Channel::Auto => {
for p in chrome {
v.push(PathBuf::from(p));
}
}
Channel::Brave => {
for p in brave {
v.push(PathBuf::from(p));
}
}
Channel::Edge => {
for p in edge {
v.push(PathBuf::from(p));
}
}
}
}
v
}
fn which_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let full = dir.join(name);
if full.is_file() {
return Some(full);
}
#[cfg(target_os = "windows")]
{
let with_exe = dir.join(format!("{name}.exe"));
if with_exe.is_file() {
return Some(with_exe);
}
}
}
None
}
pub(crate) fn debug_host_port_from_ws(ws_url: &str) -> Option<String> {
let after_scheme = ws_url
.strip_prefix("ws://")
.or_else(|| ws_url.strip_prefix("wss://"))?;
let authority = after_scheme
.split('/')
.next()
.unwrap_or(after_scheme)
.trim();
if authority.is_empty() {
None
} else {
Some(authority.to_string())
}
}
pub(crate) fn parse_devtools_line(line: &str) -> Option<String> {
let needle = "DevTools listening on ";
let idx = line.find(needle)?;
let rest = &line[idx + needle.len()..];
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let url = rest[..end].trim();
if url.starts_with("ws://") || url.starts_with("wss://") {
Some(url.to_string())
} else {
None
}
}
fn persisted_seed(dir: &Path) -> Seed {
let path = dir.join(".zd_persona_seed");
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(value) = contents.trim().parse::<u64>() {
return Seed::from_u64(value);
}
}
let seed = Seed::random();
let _ = std::fs::create_dir_all(dir);
let _ = std::fs::write(&path, seed.value().to_string());
seed
}
#[derive(Default, Clone)]
pub struct BrowserBuilder {
pub(crate) headless: Option<bool>,
pub(crate) executable: Option<PathBuf>,
pub(crate) user_data_dir: Option<PathBuf>,
pub(crate) downloads_dir: Option<PathBuf>,
pub(crate) lang: Option<String>,
pub(crate) user_agent: Option<String>,
pub(crate) sandbox: Option<bool>,
pub(crate) channel: Channel,
pub(crate) extensions: Vec<PathBuf>,
pub(crate) expert: bool,
pub(crate) force_open_shadow_roots: bool,
pub(crate) extra_args: Vec<String>,
pub(crate) stealth: Option<StealthProfile>,
pub(crate) persona: Option<Persona>,
pub(crate) persona_overlay: Option<Persona>,
pub(crate) surface_overrides: Vec<(Surface, Strategy)>,
pub(crate) extra_observers: Vec<Arc<dyn TargetObserver>>,
pub(crate) preferences: Vec<(String, serde_json::Value)>,
#[cfg(feature = "interception")]
pub(crate) proxy_auth: Option<(String, String)>,
}
impl std::fmt::Debug for BrowserBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("BrowserBuilder");
s.field("headless", &self.headless)
.field("executable", &self.executable)
.field("user_data_dir", &self.user_data_dir)
.field("downloads_dir", &self.downloads_dir)
.field("lang", &self.lang)
.field("user_agent", &self.user_agent)
.field("sandbox", &self.sandbox)
.field("channel", &self.channel)
.field("extensions", &self.extensions)
.field("expert", &self.expert)
.field("force_open_shadow_roots", &self.force_open_shadow_roots)
.field("extra_args", &self.extra_args)
.field("stealth", &self.stealth)
.field("persona", &self.persona)
.field("persona_overlay", &self.persona_overlay)
.field("surface_overrides", &self.surface_overrides)
.field(
"extra_observers",
&format_args!("<{} observers>", self.extra_observers.len()),
)
.field("preferences", &self.preferences);
#[cfg(feature = "interception")]
s.field(
"proxy_auth",
&self
.proxy_auth
.as_ref()
.map(|(u, _)| format!("Some({u:?}, <redacted>)"))
.unwrap_or_else(|| "None".into()),
);
s.finish()
}
}
impl BrowserBuilder {
#[must_use]
pub fn new() -> Self {
Self {
stealth: Some(StealthProfile::native()),
..Self::default()
}
}
#[must_use]
pub fn headless(mut self, on: bool) -> Self {
self.headless = Some(on);
self
}
#[must_use]
pub fn executable(mut self, path: impl Into<PathBuf>) -> Self {
self.executable = Some(path.into());
self
}
#[must_use]
pub fn user_data_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.user_data_dir = Some(path.into());
self
}
#[cfg(feature = "interception")]
#[must_use]
pub fn proxy_auth(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
self.proxy_auth = Some((user.into(), pass.into()));
self
}
#[must_use]
pub fn downloads_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.downloads_dir = Some(path.into());
self
}
#[must_use]
pub fn lang(mut self, lang: impl Into<String>) -> Self {
self.lang = Some(lang.into());
self
}
#[must_use]
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
#[must_use]
pub fn sandbox(mut self, on: bool) -> Self {
self.sandbox = Some(on);
self
}
#[must_use]
pub fn channel(mut self, channel: Channel) -> Self {
self.channel = channel;
self
}
#[must_use]
pub fn add_extension(mut self, path: impl Into<PathBuf>) -> Self {
self.extensions.push(path.into());
self
}
#[must_use]
pub fn extensions(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
self.extensions.extend(paths);
self
}
#[must_use]
pub fn expert(mut self, on: bool) -> Self {
self.expert = on;
self
}
#[must_use]
pub fn force_open_shadow_roots(mut self, on: bool) -> Self {
self.force_open_shadow_roots = on;
self
}
#[must_use]
pub fn arg(mut self, flag: impl Into<String>) -> Self {
self.extra_args.push(flag.into());
self
}
#[must_use]
pub fn args(mut self, flags: impl IntoIterator<Item = String>) -> Self {
self.extra_args.extend(flags);
self
}
#[must_use]
pub fn preference(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.preferences.push((key.into(), value));
self
}
#[must_use]
pub fn stealth(mut self, profile: StealthProfile) -> Self {
self.stealth = Some(profile);
self
}
#[must_use]
pub fn persona(mut self, persona: Persona) -> Self {
self.persona = Some(persona);
self
}
#[must_use]
pub fn persona_overlay(mut self, overlay: Persona) -> Self {
self.persona_overlay = Some(overlay);
self
}
#[must_use]
pub fn surface(mut self, surface: Surface, strategy: Strategy) -> Self {
self.surface_overrides.push((surface, strategy));
self
}
#[must_use]
pub fn resolved_persona(&self) -> Persona {
let explicit_seed = self
.persona
.as_ref()
.and_then(|p| p.seed)
.or_else(|| self.persona_overlay.as_ref().and_then(|p| p.seed));
let mut persona = self.persona.clone().unwrap_or_else(Persona::system);
if let Some(overlay) = &self.persona_overlay {
persona = persona.overlay(overlay.clone());
}
for (surface, strategy) in &self.surface_overrides {
persona.apply_surface_override(*surface, *strategy);
}
if explicit_seed.is_none() {
if let Some(dir) = &self.user_data_dir {
persona.seed = Some(persisted_seed(dir));
}
}
persona
}
#[must_use]
pub fn observer(mut self, obs: Arc<dyn TargetObserver>) -> Self {
self.extra_observers.push(obs);
self
}
pub(crate) fn build_flags(&self, user_data_dir: &Path) -> Vec<String> {
let mut v = Vec::with_capacity(10 + self.extra_args.len());
v.push("--remote-debugging-port=0".to_string());
v.push(format!("--user-data-dir={}", user_data_dir.display()));
v.push("--no-first-run".to_string());
v.push("--no-default-browser-check".to_string());
v.push("--password-store=basic".to_string());
v.push("--disable-save-password-bubble".to_string());
if self.extensions.is_empty() {
v.push(
"--disable-features=PasswordManagerOnboarding,AutofillServerCommunication"
.to_string(),
);
} else {
v.push(
"--disable-features=PasswordManagerOnboarding,AutofillServerCommunication,DisableLoadExtensionCommandLineSwitch"
.to_string(),
);
}
if self.headless.unwrap_or(true) {
v.push("--headless=new".to_string());
v.push("--disable-gpu".to_string());
}
if self.expert {
v.push("--disable-web-security".to_string());
v.push("--disable-site-isolation-trials".to_string());
}
if !self.extensions.is_empty() {
let joined = self
.extensions
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(",");
v.push(format!("--load-extension={joined}"));
v.push(format!("--disable-extensions-except={joined}"));
v.push("--enable-unsafe-extension-debugging".to_string());
}
if let Some(lang) = self.lang.as_ref() {
v.push(format!("--lang={lang}"));
}
if let Some(ua) = self.user_agent.as_ref() {
v.push(format!("--user-agent={ua}"));
}
if self.sandbox == Some(false) {
v.push("--no-sandbox".to_string());
}
v.extend(self.extra_args.iter().cloned());
v
}
}
#[derive(Clone, Debug)]
pub struct Browser {
pub(crate) inner: Arc<BrowserInner>,
}
#[derive(Debug)]
pub(crate) struct BrowserInner {
pub(crate) conn: Connection,
pub(crate) main_tab: Tab,
pub(crate) child: tokio::sync::Mutex<Option<Child>>,
pub(crate) _user_data: Option<TempDir>,
pub(crate) _extension_dirs: Vec<TempDir>,
pub(crate) owns_process: bool,
#[allow(dead_code)]
pub(crate) stealth_input_profile: zendriver_stealth::InputProfile,
pub(crate) tabs: tokio::sync::RwLock<HashMap<String, Tab>>,
pub(crate) debug_host_port: Option<String>,
pub(crate) ws_url: Option<String>,
pub(crate) tabs_changed: tokio::sync::Notify,
#[cfg(feature = "interception")]
#[allow(dead_code)]
pub(crate) proxy_auth_handle: std::sync::OnceLock<zendriver_interception::InterceptHandle>,
}
impl BrowserInner {
pub(crate) async fn dispose_browser_context(&self, id: &str) -> Result<(), ZendriverError> {
self.conn
.call_raw(
"Target.disposeBrowserContext",
json!({ "browserContextId": id }),
None,
)
.await?;
Ok(())
}
}
#[cfg(test)]
pub(crate) fn test_only_browser_from_conn(conn: Connection) -> Browser {
Browser {
inner: test_only_inner_from_conn(conn),
}
}
#[cfg(test)]
pub(crate) fn test_only_inner_from_conn(conn: Connection) -> Arc<BrowserInner> {
let input_profile = zendriver_stealth::InputProfile::native();
Arc::new_cyclic(|weak: &std::sync::Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn,
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
_extension_dirs: Vec::new(),
owns_process: false,
stealth_input_profile: input_profile,
tabs: tokio::sync::RwLock::new(map),
debug_host_port: None,
ws_url: None,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
})
}
pub(crate) struct TabRegistrar {
browser: OnceLock<Weak<BrowserInner>>,
input_profile: zendriver_stealth::InputProfile,
}
impl TabRegistrar {
fn new(input_profile: zendriver_stealth::InputProfile) -> Self {
Self {
browser: OnceLock::new(),
input_profile,
}
}
fn set_browser(&self, browser: Weak<BrowserInner>) {
let _ = self.browser.set(browser);
}
}
#[async_trait::async_trait]
impl TargetObserver for TabRegistrar {
fn name(&self) -> &'static str {
"tab-registrar"
}
async fn on_target_attached(&self, session: PausedSession<'_>) -> Result<(), ObserverError> {
let Some(weak) = self.browser.get() else {
warn!("TabRegistrar fired before browser weak ref was wired; skipping");
return Ok(());
};
let Some(browser) = weak.upgrade() else {
return Ok(());
};
match session.target_info.kind.as_str() {
"iframe" => {
let conn = session.connection().clone();
let new_session = SessionHandle::new(conn, session.session_id.to_string());
crate::frame::oopif::register_oopif_frame(
&browser,
session.target_info,
new_session,
)
.await;
Ok(())
}
"page" => {
let url = &session.target_info.url;
if url.starts_with("chrome://")
|| url.starts_with("devtools://")
|| url.starts_with("chrome-extension://")
|| url.starts_with("chrome-untrusted://")
{
return Ok(());
}
let conn = session.connection().clone();
let new_session = SessionHandle::new(conn, session.session_id.to_string());
let input = InputController::new(self.input_profile.clone());
let weak_inner = Arc::downgrade(&browser);
let tab = Tab::new(
new_session,
weak_inner,
input,
session.target_info.target_id.clone(),
);
let target_id_str = session.target_info.target_id.clone();
let mut tabs = browser.tabs.write().await;
tabs.retain(|_, t| t.target_id() != target_id_str);
tabs.insert(session.session_id.to_string(), tab);
drop(tabs);
browser.tabs_changed.notify_waiters();
Ok(())
}
_ => {
Ok(())
}
}
}
async fn on_target_detached(&self, session_id: &str) {
let Some(weak) = self.browser.get() else {
return;
};
let Some(browser) = weak.upgrade() else {
return;
};
let removed_tab = browser.tabs.write().await.remove(session_id).is_some();
if removed_tab {
browser.tabs_changed.notify_waiters();
} else {
let _ = crate::frame::oopif::deregister_oopif_frame(&browser, session_id).await;
}
}
}
const WS_ENDPOINT_TIMEOUT: Duration = Duration::from_secs(15);
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
const JSON_VERSION_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) struct FinishConnect {
pub(crate) conn: Connection,
pub(crate) registrar: Arc<TabRegistrar>,
pub(crate) input_profile: zendriver_stealth::InputProfile,
pub(crate) child: Option<Child>,
pub(crate) owned_tmp: Option<TempDir>,
pub(crate) extension_dirs: Vec<TempDir>,
pub(crate) debug_host_port: Option<String>,
pub(crate) ws_url: Option<String>,
pub(crate) owns_process: bool,
}
pub(crate) async fn finish_connect(
args: FinishConnect,
) -> Result<Arc<BrowserInner>, ZendriverError> {
let FinishConnect {
conn,
registrar,
input_profile,
child,
owned_tmp,
extension_dirs,
debug_host_port,
ws_url,
owns_process,
} = args;
conn.call_raw(
"Target.setAutoAttach",
json!({
"autoAttach": true,
"waitForDebuggerOnStart": true,
"flatten": true,
}),
None,
)
.await?;
let list = conn.call_raw("Target.getTargets", json!({}), None).await?;
let target_id = list["targetInfos"]
.as_array()
.and_then(|arr| {
arr.iter()
.find(|t| t["type"] == "page")
.or_else(|| arr.first())
})
.and_then(|t| t["targetId"].as_str())
.ok_or_else(|| ZendriverError::Navigation("no initial target found".into()))?
.to_string();
let attach = conn
.call_raw(
"Target.attachToTarget",
json!({ "targetId": target_id, "flatten": true }),
None,
)
.await?;
let session_id = attach["sessionId"]
.as_str()
.ok_or_else(|| ZendriverError::Navigation("attach returned no sessionId".into()))?
.to_string();
let session_id_for_registry = session_id.clone();
let target_id_for_main_tab = target_id.clone();
let inner = Arc::new_cyclic(|weak: &std::sync::Weak<BrowserInner>| {
let session = SessionHandle::new(conn.clone(), session_id);
let main_tab_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(
session,
weak.clone(),
main_tab_input,
target_id_for_main_tab,
);
BrowserInner {
conn,
main_tab,
child: tokio::sync::Mutex::new(child),
_user_data: owned_tmp,
_extension_dirs: extension_dirs,
owns_process,
stealth_input_profile: input_profile,
tabs: tokio::sync::RwLock::new(HashMap::new()),
debug_host_port,
ws_url,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
inner
.tabs
.write()
.await
.insert(session_id_for_registry, inner.main_tab.clone());
Ok(inner)
}
pub(crate) async fn resolve_ws_from_http(endpoint: &str) -> Result<String, ZendriverError> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
let after_scheme = endpoint
.strip_prefix("http://")
.or_else(|| endpoint.strip_prefix("https://"))
.ok_or(BrowserError::DevtoolsParse)?;
let authority = after_scheme
.split('/')
.next()
.unwrap_or(after_scheme)
.trim();
if authority.is_empty() {
return Err(BrowserError::DevtoolsParse.into());
}
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) => (
h,
p.parse::<u16>().map_err(|_| BrowserError::DevtoolsParse)?,
),
None => (authority, 80u16),
};
let body = timeout(JSON_VERSION_TIMEOUT, async {
let mut stream = TcpStream::connect((host, port))
.await
.map_err(BrowserError::SpawnFailed)?;
let req = format!(
"GET /json/version HTTP/1.0\r\nHost: {authority}\r\nAccept: application/json\r\nConnection: close\r\n\r\n"
);
stream
.write_all(req.as_bytes())
.await
.map_err(BrowserError::SpawnFailed)?;
stream.flush().await.map_err(BrowserError::SpawnFailed)?;
let mut buf = Vec::new();
stream
.read_to_end(&mut buf)
.await
.map_err(BrowserError::SpawnFailed)?;
Ok::<Vec<u8>, ZendriverError>(buf)
})
.await
.map_err(|_| BrowserError::WsTimeout)??;
parse_ws_from_json_version(&body)
}
pub(crate) fn parse_ws_from_json_version(raw: &[u8]) -> Result<String, ZendriverError> {
let text = String::from_utf8_lossy(raw);
let body = text
.split_once("\r\n\r\n")
.map(|(_, b)| b)
.unwrap_or(&text)
.trim();
if body.is_empty() {
return Err(BrowserError::DevtoolsParse.into());
}
let parsed: serde_json::Value =
serde_json::from_str(body).map_err(|_| BrowserError::DevtoolsParse)?;
parsed["webSocketDebuggerUrl"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| BrowserError::DevtoolsParse.into())
}
async fn resolve_extension_dirs(
extensions: &mut [PathBuf],
) -> Result<Vec<TempDir>, ZendriverError> {
let mut tempdirs = Vec::new();
for slot in extensions.iter_mut() {
if slot.is_dir() {
continue;
}
let is_crx = slot
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("crx"));
if !is_crx {
return Err(BrowserError::ExtensionLoad {
path: slot.clone(),
reason: if slot.exists() {
"extension path is neither a directory nor a .crx file".into()
} else {
"extension path does not exist".into()
},
}
.into());
}
let (dir, td) = unzip_crx(slot).await?;
*slot = dir;
tempdirs.push(td);
}
Ok(tempdirs)
}
async fn unzip_crx(crx_path: &Path) -> Result<(PathBuf, TempDir), ZendriverError> {
let crx_path = crx_path.to_path_buf();
let td = tempfile::Builder::new()
.prefix("zendriver-ext-")
.tempdir()
.map_err(BrowserError::SpawnFailed)?;
let dest = td.path().to_path_buf();
let dest_for_blocking = dest.clone();
let crx_for_blocking = crx_path.clone();
tokio::task::spawn_blocking(move || unzip_crx_blocking(&crx_for_blocking, &dest_for_blocking))
.await
.map_err(|e| BrowserError::ExtensionLoad {
path: crx_path.clone(),
reason: format!("unzip task join error: {e}"),
})??;
Ok((dest, td))
}
fn unzip_crx_blocking(crx_path: &Path, dest_dir: &Path) -> Result<(), ZendriverError> {
use std::io::Cursor;
let bytes = std::fs::read(crx_path).map_err(|e| BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: format!("read failed: {e}"),
})?;
let zip_start = bytes
.windows(4)
.position(|w| w == [0x50, 0x4B, 0x03, 0x04])
.ok_or_else(|| BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: "no ZIP payload found in .crx".into(),
})?;
let mut archive = zip::ZipArchive::new(Cursor::new(&bytes[zip_start..])).map_err(|e| {
BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: format!("not a valid ZIP: {e}"),
}
})?;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: format!("zip entry {i}: {e}"),
})?;
let Some(rel_path) = entry.enclosed_name() else {
return Err(BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: format!("zip entry has unsafe path: {}", entry.name()),
}
.into());
};
#[cfg(unix)]
if let Some(mode) = entry.unix_mode() {
const S_IFMT: u32 = 0o170_000;
const S_IFLNK: u32 = 0o120_000;
if mode & S_IFMT == S_IFLNK {
return Err(BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: format!("zip entry {rel_path:?} is a symlink; refusing"),
}
.into());
}
}
let out_path = dest_dir.join(&rel_path);
if entry.is_dir() {
std::fs::create_dir_all(&out_path).map_err(|e| BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: format!("mkdir {out_path:?}: {e}"),
})?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: format!("mkdir {parent:?}: {e}"),
})?;
}
let mut out =
std::fs::File::create(&out_path).map_err(|e| BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: format!("create {out_path:?}: {e}"),
})?;
std::io::copy(&mut entry, &mut out).map_err(|e| BrowserError::ExtensionLoad {
path: crx_path.to_path_buf(),
reason: format!("write {out_path:?}: {e}"),
})?;
}
Ok(())
}
impl BrowserBuilder {
pub async fn launch(mut self) -> Result<Browser, ZendriverError> {
let exe = match self.executable.clone() {
Some(p) => p,
None => match std::env::var("CHROME_BIN").ok().filter(|s| !s.is_empty()) {
Some(p) => PathBuf::from(p),
None => find_chrome_executable_for_channel(self.channel)?,
},
};
let extension_dirs = resolve_extension_dirs(&mut self.extensions).await?;
let input_profile = self
.stealth
.as_ref()
.map_or_else(zendriver_stealth::InputProfile::native, |sp| {
sp.input_profile()
});
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut observers, extra_flags): (Vec<Arc<dyn TargetObserver>>, Vec<String>) =
if let Some(ref profile) = self.stealth {
let fp = profile.resolve_fingerprint(&exe)?;
let stealth_obs: Arc<dyn TargetObserver> = Arc::new(StealthObserver::with_persona(
profile.clone(),
fp,
self.resolved_persona(),
));
let mut obs_vec = Vec::with_capacity(3 + self.extra_observers.len());
obs_vec.push(stealth_obs);
obs_vec.push(registrar.clone() as Arc<dyn TargetObserver>);
obs_vec.extend(self.extra_observers.iter().cloned());
(obs_vec, profile.build_flags())
} else {
let mut obs_vec = Vec::with_capacity(2 + self.extra_observers.len());
obs_vec.push(registrar.clone() as Arc<dyn TargetObserver>);
obs_vec.extend(self.extra_observers.iter().cloned());
(obs_vec, Vec::new())
};
if self.force_open_shadow_roots {
observers.push(Arc::new(crate::expert::ShadowRootObserver) as Arc<dyn TargetObserver>);
}
let (user_data_path, owned_tmp) = match self.user_data_dir.clone() {
Some(p) => (p, None),
None => {
let td = tempfile::Builder::new()
.prefix("zendriver-")
.tempdir()
.map_err(BrowserError::SpawnFailed)?;
(td.path().to_path_buf(), Some(td))
}
};
crate::preferences::write_preferences(
&user_data_path,
owned_tmp.is_some(),
&self.preferences,
);
let mut flags = self.build_flags(&user_data_path);
flags.extend(extra_flags);
if std::env::var("CI").is_ok() {
for needed in ["--no-sandbox", "--disable-dev-shm-usage"] {
if !flags.iter().any(|f| f == needed) {
flags.push(needed.into());
}
}
}
info!(executable = %exe.display(), "launching chrome");
let mut cmd = Command::new(&exe);
cmd.args(&flags)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(BrowserError::SpawnFailed)?;
let stderr = child.stderr.take().ok_or(BrowserError::DevtoolsParse)?;
let mut lines = BufReader::new(stderr).lines();
let ws_url = timeout(WS_ENDPOINT_TIMEOUT, async {
while let Ok(Some(line)) = lines.next_line().await {
debug!(line = %line, "chrome stderr");
if let Some(url) = parse_devtools_line(&line) {
return Ok::<String, ZendriverError>(url);
}
}
Err(BrowserError::DevtoolsParse.into())
})
.await
.map_err(|_| BrowserError::WsTimeout)??;
debug!(ws_url = %ws_url, "connecting to chrome");
let conn = zendriver_transport::connect_with_observers(&ws_url, observers).await?;
let inner = finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: Some(child),
owned_tmp,
extension_dirs,
debug_host_port: debug_host_port_from_ws(&ws_url),
ws_url: Some(ws_url),
owns_process: true,
})
.await?;
if let Some(dir) = self.downloads_dir.as_ref() {
inner
.conn
.call_raw(
"Browser.setDownloadBehavior",
json!({
"behavior": "allow",
"downloadPath": dir.display().to_string(),
"eventsEnabled": true,
}),
None,
)
.await?;
}
#[cfg(feature = "interception")]
if let Some((user, pass)) = self.proxy_auth.clone() {
let main_session = inner.main_tab.session().clone();
let handle = zendriver_interception::InterceptBuilder::new(&main_session)
.handle_auth(user, pass)
.start();
let _ = inner.proxy_auth_handle.set(handle);
}
Ok(Browser { inner })
}
pub async fn connect(self, endpoint: impl Into<String>) -> Result<Browser, ZendriverError> {
let endpoint = endpoint.into();
let ws_url = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
endpoint
} else if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
resolve_ws_from_http(&endpoint).await?
} else {
return Err(BrowserError::DevtoolsParse.into());
};
let input_profile = self
.stealth
.as_ref()
.map_or_else(zendriver_stealth::InputProfile::native, |sp| {
sp.input_profile()
});
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let mut observers: Vec<Arc<dyn TargetObserver>> = if let Some(ref profile) = self.stealth {
let fp = profile.resolve_fingerprint(Path::new(""))?;
let stealth_obs: Arc<dyn TargetObserver> = Arc::new(StealthObserver::with_persona(
profile.clone(),
fp,
self.resolved_persona(),
));
let mut obs_vec = Vec::with_capacity(3 + self.extra_observers.len());
obs_vec.push(stealth_obs);
obs_vec.push(registrar.clone() as Arc<dyn TargetObserver>);
obs_vec.extend(self.extra_observers.iter().cloned());
obs_vec
} else {
let mut obs_vec = Vec::with_capacity(2 + self.extra_observers.len());
obs_vec.push(registrar.clone() as Arc<dyn TargetObserver>);
obs_vec.extend(self.extra_observers.iter().cloned());
obs_vec
};
if self.force_open_shadow_roots {
observers.push(Arc::new(crate::expert::ShadowRootObserver) as Arc<dyn TargetObserver>);
}
debug!(ws_url = %ws_url, "attaching to running chrome");
let conn = zendriver_transport::connect_with_observers(&ws_url, observers).await?;
let inner = finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: None,
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: debug_host_port_from_ws(&ws_url),
ws_url: Some(ws_url),
owns_process: false,
})
.await?;
Ok(Browser { inner })
}
}
#[cfg(feature = "fetcher")]
impl BrowserBuilder {
pub async fn ensure_chrome(self) -> Result<Self, ZendriverError> {
let path = zendriver_fetcher::Fetcher::new().ensure_chrome().await?;
Ok(self.executable(path))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum PermissionType {
AccessibilityEvents,
AudioCapture,
BackgroundSync,
BackgroundFetch,
Camera,
ClipboardReadWrite,
ClipboardSanitizedWrite,
DisplayCapture,
DurableStorage,
Geolocation,
IdleDetection,
LocalFonts,
Midi,
MidiSysex,
Microphone,
Nfc,
Notifications,
PaymentHandler,
PeriodicBackgroundSync,
ProtectedMediaIdentifier,
Sensors,
StorageAccess,
TopLevelStorageAccess,
VideoCapture,
VideoCapturePanTiltZoom,
WakeLockScreen,
WakeLockSystem,
WindowManagement,
}
impl PermissionType {
pub const ALL: &'static [PermissionType] = &[
PermissionType::AccessibilityEvents,
PermissionType::AudioCapture,
PermissionType::BackgroundSync,
PermissionType::BackgroundFetch,
PermissionType::ClipboardReadWrite,
PermissionType::ClipboardSanitizedWrite,
PermissionType::DisplayCapture,
PermissionType::DurableStorage,
PermissionType::Geolocation,
PermissionType::IdleDetection,
PermissionType::LocalFonts,
PermissionType::Midi,
PermissionType::MidiSysex,
PermissionType::Nfc,
PermissionType::Notifications,
PermissionType::PaymentHandler,
PermissionType::PeriodicBackgroundSync,
PermissionType::ProtectedMediaIdentifier,
PermissionType::Sensors,
PermissionType::StorageAccess,
PermissionType::TopLevelStorageAccess,
PermissionType::VideoCapture,
PermissionType::VideoCapturePanTiltZoom,
PermissionType::WakeLockScreen,
PermissionType::WakeLockSystem,
PermissionType::WindowManagement,
];
#[must_use]
pub fn as_cdp(&self) -> &'static str {
match self {
PermissionType::AccessibilityEvents => "accessibilityEvents",
PermissionType::AudioCapture | PermissionType::Microphone => "audioCapture",
PermissionType::BackgroundSync => "backgroundSync",
PermissionType::BackgroundFetch => "backgroundFetch",
PermissionType::ClipboardReadWrite => "clipboardReadWrite",
PermissionType::ClipboardSanitizedWrite => "clipboardSanitizedWrite",
PermissionType::DisplayCapture => "displayCapture",
PermissionType::DurableStorage => "durableStorage",
PermissionType::Geolocation => "geolocation",
PermissionType::IdleDetection => "idleDetection",
PermissionType::LocalFonts => "localFonts",
PermissionType::Midi => "midi",
PermissionType::MidiSysex => "midiSysex",
PermissionType::Nfc => "nfc",
PermissionType::Notifications => "notifications",
PermissionType::PaymentHandler => "paymentHandler",
PermissionType::PeriodicBackgroundSync => "periodicBackgroundSync",
PermissionType::ProtectedMediaIdentifier => "protectedMediaIdentifier",
PermissionType::Sensors => "sensors",
PermissionType::StorageAccess => "storageAccess",
PermissionType::TopLevelStorageAccess => "topLevelStorageAccess",
PermissionType::VideoCapture | PermissionType::Camera => "videoCapture",
PermissionType::VideoCapturePanTiltZoom => "videoCapturePanTiltZoom",
PermissionType::WakeLockScreen => "wakeLockScreen",
PermissionType::WakeLockSystem => "wakeLockSystem",
PermissionType::WindowManagement => "windowManagement",
}
}
}
impl Browser {
#[must_use]
pub fn builder() -> BrowserBuilder {
BrowserBuilder::new()
}
pub async fn connect(endpoint: impl Into<String>) -> Result<Browser, ZendriverError> {
BrowserBuilder::new().connect(endpoint).await
}
#[must_use]
pub fn main_tab(&self) -> Tab {
self.inner.main_tab.clone()
}
#[must_use]
pub fn cdp(&self) -> &Connection {
&self.inner.conn
}
#[must_use]
pub fn cookies(&self) -> crate::CookieJar {
crate::CookieJar::new(self.inner.conn.clone())
}
pub async fn set_download_path(&self, dir: impl Into<PathBuf>) -> Result<(), ZendriverError> {
let dir = dir.into();
self.inner
.conn
.call_raw(
"Browser.setDownloadBehavior",
json!({
"behavior": "allow",
"downloadPath": dir.to_string_lossy().to_string(),
}),
None,
)
.await?;
Ok(())
}
pub async fn create_browser_context_with(
&self,
proxy_server: Option<&str>,
proxy_bypass_list: Option<&str>,
) -> Result<crate::BrowserContext, ZendriverError> {
let mut params = serde_json::Map::new();
if let Some(p) = proxy_server {
params.insert(
"proxyServer".into(),
serde_json::Value::String(p.to_string()),
);
}
if let Some(b) = proxy_bypass_list {
params.insert(
"proxyBypassList".into(),
serde_json::Value::String(b.to_string()),
);
}
let res = self
.inner
.conn
.call_raw(
"Target.createBrowserContext",
serde_json::Value::Object(params),
None,
)
.await?;
let id = res
.get("browserContextId")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ZendriverError::Navigation(
"Target.createBrowserContext returned no browserContextId".into(),
)
})?
.to_string();
Ok(crate::BrowserContext {
browser: self.inner.clone(),
id,
})
}
pub async fn create_browser_context(&self) -> Result<crate::BrowserContext, ZendriverError> {
self.create_browser_context_with(None, None).await
}
pub async fn new_tab(&self) -> Result<Tab, ZendriverError> {
self.new_tab_at("about:blank").await
}
pub async fn new_tab_at(&self, url: impl Into<String>) -> Result<Tab, ZendriverError> {
self.create_target(url.into(), false).await
}
pub async fn new_window(&self) -> Result<Tab, ZendriverError> {
self.create_target("about:blank".to_string(), true).await
}
pub async fn new_window_at(&self, url: impl Into<String>) -> Result<Tab, ZendriverError> {
self.create_target(url.into(), true).await
}
async fn create_target(&self, url: String, new_window: bool) -> Result<Tab, ZendriverError> {
let mut params = json!({ "url": url });
if new_window {
params["newWindow"] = serde_json::Value::Bool(true);
}
let res = self
.inner
.conn
.call_raw("Target.createTarget", params, None)
.await?;
let target_id = res
.get("targetId")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ZendriverError::Navigation("Target.createTarget returned no targetId".into())
})?
.to_string();
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let notif = self.inner.tabs_changed.notified();
tokio::pin!(notif);
notif.as_mut().enable();
{
let tabs = self.inner.tabs.read().await;
if let Some(tab) = tabs.values().find(|t| t.target_id() == target_id) {
return Ok(tab.clone());
}
}
if tokio::time::Instant::now() >= deadline {
return Err(ZendriverError::TabNotFound(target_id));
}
tokio::select! {
() = notif => {}
() = tokio::time::sleep_until(deadline) => {}
}
}
}
pub async fn tabs(&self) -> Vec<Tab> {
self.inner.tabs.read().await.values().cloned().collect()
}
pub async fn tab_count(&self) -> usize {
self.inner.tabs.read().await.len()
}
pub async fn reconnect(&self) -> Result<(), ZendriverError> {
let ws_url = self
.inner
.ws_url
.clone()
.ok_or(ZendriverError::Disconnected)?;
self.inner.conn.redial(&ws_url).await?;
self.inner.tabs.write().await.clear();
self.inner
.conn
.call_raw(
"Target.setAutoAttach",
json!({
"autoAttach": true,
"waitForDebuggerOnStart": true,
"flatten": true,
}),
None,
)
.await?;
self.inner.tabs_changed.notify_waiters();
Ok(())
}
pub async fn close(self) -> Result<(), ZendriverError> {
self.inner.conn.shutdown();
if !self.inner.owns_process {
return Ok(());
}
let mut child_guard = self.inner.child.lock().await;
if let Some(mut child) = child_guard.take() {
#[cfg(unix)]
{
if let Some(pid) = child.id() {
#[allow(unsafe_code)]
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
}
#[cfg(not(unix))]
{
let _ = child.start_kill(); }
match timeout(SHUTDOWN_GRACE, child.wait()).await {
Ok(Ok(_status)) => {}
_ => {
let _ = child.kill().await;
}
}
}
Ok(())
}
pub async fn grant_permissions(
&self,
perms: &[PermissionType],
origin: Option<&str>,
) -> Result<(), ZendriverError> {
let mut params = serde_json::Map::new();
let permissions: Vec<serde_json::Value> = perms
.iter()
.map(|p| serde_json::Value::String(p.as_cdp().to_string()))
.collect();
params.insert("permissions".into(), serde_json::Value::Array(permissions));
if let Some(o) = origin {
params.insert("origin".into(), serde_json::Value::String(o.to_string()));
}
self.inner
.conn
.call_raw(
"Browser.grantPermissions",
serde_json::Value::Object(params),
None,
)
.await?;
Ok(())
}
pub async fn grant_all_permissions(&self) -> Result<(), ZendriverError> {
self.grant_permissions(PermissionType::ALL, None).await
}
pub async fn reset_permissions(&self) -> Result<(), ZendriverError> {
self.inner
.conn
.call_raw("Browser.resetPermissions", json!({}), None)
.await?;
Ok(())
}
}
impl Drop for BrowserInner {
fn drop(&mut self) {
self.conn.shutdown();
debug_assert!(
self.owns_process || self.child.try_lock().map(|g| g.is_none()).unwrap_or(true),
"a non-owning Browser must not hold a Child handle",
);
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn candidate_paths_is_nonempty() {
let v = candidate_paths_for_channel(Channel::Auto);
assert!(!v.is_empty());
}
#[test]
fn builder_accepts_persona_and_overrides() {
let b = Browser::builder()
.persona(Persona::builder().device_memory_gb(16).build())
.persona_overlay(r#"{"timezone":"UTC"}"#.parse().unwrap())
.surface(Surface::Webrtc, Strategy::Native);
let p = b.resolved_persona();
assert_eq!(p.device_memory_gb, Some(16));
assert_eq!(p.timezone.as_deref(), Some("UTC"));
assert_eq!(
p.webrtc.as_ref().and_then(|w| w.strategy),
Some(Strategy::Native),
"surface override must set the WebRTC strategy"
);
}
#[test]
fn seed_persists_in_user_data_dir() {
let dir = tempfile::tempdir().unwrap();
let s1 = Browser::builder()
.user_data_dir(dir.path())
.resolved_persona()
.seed
.unwrap();
let s2 = Browser::builder()
.user_data_dir(dir.path())
.resolved_persona()
.seed
.unwrap();
assert_eq!(s1, s2, "same profile dir → same seed across builders");
}
#[test]
fn explicit_seed_overrides_user_data_dir_persistence() {
let dir = tempfile::tempdir().unwrap();
let pinned = Browser::builder()
.persona(Persona::builder().seed(Seed::from_u64(777)).build())
.user_data_dir(dir.path())
.resolved_persona()
.seed
.unwrap();
assert_eq!(pinned, Seed::from_u64(777));
assert!(!dir.path().join(".zd_persona_seed").exists());
}
#[test]
fn find_chrome_executable_returns_err_when_none_exist() {
let _ = find_chrome_executable();
}
#[test]
fn parse_devtools_line_extracts_ws_url() {
let line = "DevTools listening on ws://127.0.0.1:54321/devtools/browser/abc-def-123\n";
assert_eq!(
parse_devtools_line(line).as_deref(),
Some("ws://127.0.0.1:54321/devtools/browser/abc-def-123")
);
}
#[test]
fn parse_devtools_line_returns_none_for_unrelated() {
assert!(parse_devtools_line("loading extension foo").is_none());
assert!(parse_devtools_line("DevTools listening on http://x").is_none());
}
#[test]
fn parse_devtools_line_handles_prefixed_log_lines() {
let line = "[12345:1234:0102/030405.000000:INFO:browser.cc] DevTools listening on ws://localhost:1/devtools/browser/x";
assert_eq!(
parse_devtools_line(line).as_deref(),
Some("ws://localhost:1/devtools/browser/x")
);
}
#[test]
fn debug_host_port_from_ws_extracts_authority() {
assert_eq!(
debug_host_port_from_ws("ws://127.0.0.1:9222/devtools/browser/abc").as_deref(),
Some("127.0.0.1:9222")
);
assert_eq!(
debug_host_port_from_ws("wss://example.test:1/x").as_deref(),
Some("example.test:1")
);
assert_eq!(
debug_host_port_from_ws("ws://localhost:5555").as_deref(),
Some("localhost:5555")
);
assert_eq!(debug_host_port_from_ws("http://x:1/y"), None);
assert_eq!(debug_host_port_from_ws("nonsense"), None);
}
#[test]
fn build_flags_default_is_headless() {
let b = BrowserBuilder::new();
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(flags.contains(&"--headless=new".to_string()));
assert!(flags.contains(&"--disable-gpu".to_string()));
assert!(flags.contains(&"--user-data-dir=/tmp/x".to_string()));
assert!(flags.contains(&"--remote-debugging-port=0".to_string()));
}
#[test]
fn build_flags_suppresses_password_popups() {
let b = BrowserBuilder::new();
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(flags.contains(&"--password-store=basic".to_string()));
assert!(flags.contains(&"--disable-save-password-bubble".to_string()));
assert!(
flags
.iter()
.any(|f| f.contains("PasswordManagerOnboarding"))
);
}
#[test]
fn build_flags_no_headless_when_disabled() {
let b = BrowserBuilder::new().headless(false);
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(!flags.iter().any(|f| f.starts_with("--headless")));
assert!(!flags.contains(&"--disable-gpu".to_string()));
}
#[test]
fn build_flags_includes_extra_args_in_order() {
let b = BrowserBuilder::new()
.arg("--proxy-server=http://x")
.arg("--lang=en-US");
let flags = b.build_flags(Path::new("/tmp/x"));
let proxy = flags
.iter()
.position(|f| f == "--proxy-server=http://x")
.unwrap();
let lang = flags.iter().position(|f| f == "--lang=en-US").unwrap();
assert!(proxy < lang);
}
#[test]
fn lang_flag_present() {
let b = BrowserBuilder::new().lang("en-US");
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(flags.contains(&"--lang=en-US".to_string()));
}
#[test]
fn user_agent_flag_present() {
let b = BrowserBuilder::new().user_agent("MyAgent/1.0");
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(flags.contains(&"--user-agent=MyAgent/1.0".to_string()));
}
#[test]
fn sandbox_false_adds_no_sandbox() {
let b = BrowserBuilder::new().sandbox(false);
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(flags.contains(&"--no-sandbox".to_string()));
}
#[test]
fn sandbox_default_on_omits_no_sandbox() {
let b = BrowserBuilder::new();
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(!flags.contains(&"--no-sandbox".to_string()));
}
#[test]
fn expert_adds_web_security_and_site_isolation_flags() {
let b = BrowserBuilder::new().expert(true);
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(
flags.contains(&"--disable-web-security".to_string()),
"expected --disable-web-security in {flags:?}"
);
assert!(
flags.contains(&"--disable-site-isolation-trials".to_string()),
"expected --disable-site-isolation-trials in {flags:?}"
);
}
#[test]
fn expert_off_omits_expert_flags() {
let flags = BrowserBuilder::new().build_flags(Path::new("/tmp/x"));
assert!(!flags.contains(&"--disable-web-security".to_string()));
assert!(!flags.contains(&"--disable-site-isolation-trials".to_string()));
}
#[test]
fn extensions_add_load_and_disable_except_flags() {
let b = BrowserBuilder::new().add_extension("a").add_extension("b");
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(
flags.contains(&"--load-extension=a,b".to_string()),
"expected --load-extension=a,b in {flags:?}"
);
assert!(
flags.contains(&"--disable-extensions-except=a,b".to_string()),
"expected --disable-extensions-except=a,b in {flags:?}"
);
assert!(
flags.contains(&"--enable-unsafe-extension-debugging".to_string()),
"expected --enable-unsafe-extension-debugging in {flags:?}"
);
}
#[test]
fn extensions_force_disable_load_extension_feature_even_off_profile() {
let b = BrowserBuilder::new()
.stealth(StealthProfile::off())
.add_extension("ext");
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(
flags.iter().any(|f| f.starts_with("--disable-features=")
&& f.contains("DisableLoadExtensionCommandLineSwitch")),
"expected DisableLoadExtensionCommandLineSwitch in a --disable-features line: {flags:?}"
);
}
#[test]
fn no_extensions_omits_extension_flags() {
let flags = BrowserBuilder::new().build_flags(Path::new("/tmp/x"));
assert!(!flags.iter().any(|f| f.starts_with("--load-extension")));
assert!(
!flags
.iter()
.any(|f| f.starts_with("--disable-extensions-except"))
);
assert!(!flags.contains(&"--enable-unsafe-extension-debugging".to_string()));
assert!(
!flags
.iter()
.any(|f| f.contains("DisableLoadExtensionCommandLineSwitch")),
"default build must not carry the extension feature toggle: {flags:?}"
);
}
#[tokio::test]
async fn resolve_extension_dirs_passes_through_directories() {
let dir = tempfile::tempdir().unwrap();
let mut exts = vec![dir.path().to_path_buf()];
let tempdirs = resolve_extension_dirs(&mut exts).await.unwrap();
assert!(
tempdirs.is_empty(),
"directories should not allocate tempdirs"
);
assert_eq!(exts, vec![dir.path().to_path_buf()]);
}
#[tokio::test]
async fn resolve_extension_dirs_unzips_crx_to_tempdir() {
use std::io::Write;
let mut zip_buf = Vec::new();
{
let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
w.start_file("manifest.json", zip::write::SimpleFileOptions::default())
.unwrap();
w.write_all(br#"{"name":"t","version":"1","manifest_version":3}"#)
.unwrap();
w.finish().unwrap();
}
let mut crx = Vec::new();
crx.extend_from_slice(b"Cr24");
crx.extend_from_slice(&3u32.to_le_bytes()); crx.extend_from_slice(&0u32.to_le_bytes()); crx.extend_from_slice(&zip_buf);
let crx_file = tempfile::Builder::new().suffix(".crx").tempfile().unwrap();
std::fs::write(crx_file.path(), &crx).unwrap();
let mut exts = vec![crx_file.path().to_path_buf()];
let tempdirs = resolve_extension_dirs(&mut exts).await.unwrap();
assert_eq!(tempdirs.len(), 1, "crx should allocate one tempdir");
assert!(exts[0].is_dir(), "crx slot should resolve to a directory");
assert_ne!(exts[0], crx_file.path());
assert!(exts[0].join("manifest.json").is_file());
}
#[tokio::test]
async fn resolve_extension_dirs_errors_on_missing_path() {
let mut exts = vec![PathBuf::from("/nonexistent/zzz-does-not-exist")];
let err = resolve_extension_dirs(&mut exts).await.unwrap_err();
assert!(
matches!(
err,
ZendriverError::Browser(BrowserError::ExtensionLoad { .. })
),
"expected ExtensionLoad, got {err:?}"
);
}
#[test]
fn channel_brave_resolves_brave_path() {
let paths = candidate_paths_for_channel(Channel::Brave);
assert!(!paths.is_empty(), "Brave channel must yield candidates");
assert!(
paths
.iter()
.all(|p| p.to_string_lossy().to_lowercase().contains("brave")),
"every Brave candidate path should reference Brave: {paths:?}"
);
}
#[test]
fn channel_edge_resolves_edge_path() {
let paths = candidate_paths_for_channel(Channel::Edge);
assert!(!paths.is_empty(), "Edge channel must yield candidates");
assert!(
paths
.iter()
.all(|p| p.to_string_lossy().to_lowercase().contains("edge")),
"every Edge candidate path should reference Edge: {paths:?}"
);
}
#[test]
fn channel_auto_includes_chrome_family_candidates() {
let paths = candidate_paths_for_channel(Channel::Auto);
assert!(!paths.is_empty(), "Auto must yield candidates");
let joined = paths
.iter()
.map(|p| p.to_string_lossy().to_lowercase())
.collect::<Vec<_>>()
.join("|");
assert!(
joined.contains("chrom"),
"Auto candidates should reference a Chrome/Chromium binary: {paths:?}"
);
}
#[test]
fn default_launch_flags_snapshot() {
let b = BrowserBuilder::new();
let flags = b.build_flags(std::path::Path::new("/tmp/test-user-data"));
insta::assert_yaml_snapshot!("default_launch_flags", flags);
}
#[tokio::test]
async fn reconnect_without_ws_url_errors_disconnected() {
use zendriver_transport::testing::MockConnection;
let (_mock, conn) = MockConnection::pair();
let browser = test_only_browser_from_conn(conn);
let res = browser.reconnect().await;
assert!(
matches!(res, Err(ZendriverError::Disconnected)),
"reconnect with no ws_url must error Disconnected, got {res:?}"
);
}
#[test]
fn non_headless_launch_flags_snapshot() {
let b = BrowserBuilder::new().headless(false);
let flags = b.build_flags(std::path::Path::new("/tmp/test-user-data"));
insta::assert_yaml_snapshot!("non_headless_launch_flags", flags);
}
#[tokio::test]
async fn tab_registrar_inserts_page_target_into_tabs_map() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
_extension_dirs: Vec::new(),
owns_process: false,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
debug_host_port: None,
ws_url: None,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
assert_eq!(inner.tabs.read().await.len(), 1);
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S2",
"targetInfo": {
"targetId": "T2",
"type": "page",
"url": "about:blank",
"attached": true,
},
"waitingForDebugger": true,
}),
)
.await;
let release_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Runtime.runIfWaitingForDebugger"),
)
.await
.expect("debugger-release did not fire within 2s");
mock.reply(release_id, json!({})).await;
for _ in 0..20 {
if inner.tabs.read().await.len() == 2 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let tabs = inner.tabs.read().await;
assert_eq!(tabs.len(), 2, "expected main + new tab in registry");
assert!(tabs.contains_key("S1"), "main tab still registered");
assert!(tabs.contains_key("S2"), "new tab registered by observer");
drop(tabs);
conn.shutdown();
}
#[tokio::test]
async fn new_tab_creates_target_then_returns_registered_tab() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
_extension_dirs: Vec::new(),
owns_process: false,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
debug_host_port: None,
ws_url: None,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
let browser = Browser {
inner: inner.clone(),
};
let fut = tokio::spawn({
let b = browser.clone();
async move { b.new_tab().await }
});
let create_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Target.createTarget"),
)
.await
.expect("Target.createTarget not sent within 2s");
assert_eq!(mock.last_sent()["params"]["url"], "about:blank");
mock.reply(create_id, json!({ "targetId": "T2" })).await;
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S2",
"targetInfo": {
"targetId": "T2",
"type": "page",
"url": "about:blank",
"attached": true,
},
"waitingForDebugger": true,
}),
)
.await;
let release_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Runtime.runIfWaitingForDebugger"),
)
.await
.expect("debugger-release did not fire within 2s");
mock.reply(release_id, json!({})).await;
let new_tab = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
.await
.expect("new_tab future did not resolve within 2s")
.expect("spawned task panicked")
.expect("new_tab returned Err");
assert_eq!(new_tab.target_id(), "T2");
assert_eq!(browser.tab_count().await, 2);
let all = browser.tabs().await;
assert_eq!(all.len(), 2);
let target_ids: std::collections::HashSet<_> =
all.iter().map(|t| t.target_id().to_string()).collect();
assert!(target_ids.contains("T1"));
assert!(target_ids.contains("T2"));
conn.shutdown();
}
#[tokio::test]
async fn new_window_at_passes_new_window_true() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
_extension_dirs: Vec::new(),
owns_process: false,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
debug_host_port: None,
ws_url: None,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
let browser = Browser {
inner: inner.clone(),
};
let fut = tokio::spawn({
let b = browser.clone();
async move { b.new_window_at("https://example.com").await }
});
let create_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Target.createTarget"),
)
.await
.expect("Target.createTarget not sent within 2s");
let sent = mock.last_sent();
assert_eq!(sent["params"]["url"], "https://example.com");
assert_eq!(
sent["params"]["newWindow"], true,
"new_window_at must set newWindow:true"
);
mock.reply(create_id, json!({ "targetId": "TW" })).await;
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "SW",
"targetInfo": {
"targetId": "TW",
"type": "page",
"url": "https://example.com",
"attached": true,
},
"waitingForDebugger": true,
}),
)
.await;
let release_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Runtime.runIfWaitingForDebugger"),
)
.await
.expect("debugger-release did not fire within 2s");
mock.reply(release_id, json!({})).await;
let win = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
.await
.expect("new_window_at future did not resolve within 2s")
.expect("spawned task panicked")
.expect("new_window_at returned Err");
assert_eq!(win.target_id(), "TW");
conn.shutdown();
}
#[tokio::test]
async fn tab_registrar_removes_session_on_detached_event() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let extra_session = SessionHandle::new(conn.clone(), "S2");
let extra_input = InputController::new(input_profile.clone());
let extra_tab = Tab::new(extra_session, weak.clone(), extra_input, "T2".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
map.insert("S2".to_string(), extra_tab);
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
_extension_dirs: Vec::new(),
owns_process: false,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
debug_host_port: None,
ws_url: None,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
assert_eq!(inner.tabs.read().await.len(), 2);
mock.emit_event(
"Target.detachedFromTarget",
json!({ "sessionId": "S2", "targetId": "T2" }),
)
.await;
for _ in 0..50 {
if inner.tabs.read().await.len() == 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let tabs = inner.tabs.read().await;
assert_eq!(tabs.len(), 1, "expected S2 to be removed from registry");
assert!(tabs.contains_key("S1"), "main tab still registered");
assert!(!tabs.contains_key("S2"), "detached tab removed");
drop(tabs);
conn.shutdown();
}
#[tokio::test]
async fn browser_cookies_returns_jar_bound_to_browser_connection() {
use crate::cookies::Cookie;
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let (mut mock, conn) = MockConnection::pair();
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
_extension_dirs: Vec::new(),
owns_process: false,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
debug_host_port: None,
ws_url: None,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
let browser = Browser { inner };
let jar = browser.cookies();
let fut = tokio::spawn(async move {
jar.set(Cookie {
name: "sid".into(),
value: "abc".into(),
domain: ".example.com".into(),
path: "/".into(),
expires: None,
http_only: false,
secure: false,
same_site: None,
url: None,
..Default::default()
})
.await
});
let id = mock.expect_cmd("Storage.setCookies").await;
let params = &mock.last_sent()["params"];
let arr = params["cookies"]
.as_array()
.expect("setCookies payload must carry a cookies array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["name"], "sid");
assert!(mock.last_sent().get("sessionId").is_none());
mock.reply(id, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn tabs_and_tab_count_agree_on_initial_state() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (_mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
_extension_dirs: Vec::new(),
owns_process: false,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
debug_host_port: None,
ws_url: None,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
let browser = Browser { inner };
assert_eq!(browser.tab_count().await, 1);
let tabs = browser.tabs().await;
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0].target_id(), "T1");
conn.shutdown();
}
#[tokio::test]
async fn tab_registrar_attaches_oopif_frame_under_parent_tab() {
use crate::frame::Frame;
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
_extension_dirs: Vec::new(),
owns_process: false,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
debug_host_port: None,
ws_url: None,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
{
let main_tab = inner.tabs.read().await.get("S1").cloned().unwrap();
let parent_session = main_tab.session().clone();
let placeholder = Frame::new(
"F_OOPIF".to_string(),
Some("FROOT".to_string()),
String::new(),
None,
parent_session,
Arc::downgrade(&main_tab.inner),
);
main_tab
.inner
.frames
.write()
.await
.insert("F_OOPIF".to_string(), placeholder);
}
assert_eq!(inner.tabs.read().await.len(), 1);
let parent_tab = inner.tabs.read().await.get("S1").cloned().unwrap();
{
let frames = parent_tab.inner.frames.read().await;
let placeholder = frames.get("F_OOPIF").expect("placeholder seeded");
assert_eq!(placeholder.session().session_id(), "S1");
}
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S2",
"targetInfo": {
"targetId": "F_OOPIF",
"type": "iframe",
"url": "https://oopif.example.com/",
"attached": true,
},
"waitingForDebugger": true,
}),
)
.await;
let release_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Runtime.runIfWaitingForDebugger"),
)
.await
.expect("debugger-release did not fire within 2s");
mock.reply(release_id, json!({})).await;
for _ in 0..50 {
let frames = parent_tab.inner.frames.read().await;
if frames
.get("F_OOPIF")
.is_some_and(|f| f.session().session_id() == "S2")
{
break;
}
drop(frames);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let frames = parent_tab.inner.frames.read().await;
let oopif = frames
.get("F_OOPIF")
.expect("OOPIF frame registered on parent");
assert_eq!(
oopif.session().session_id(),
"S2",
"OOPIF frame must carry the child session, not the parent's",
);
assert_eq!(oopif.id(), "F_OOPIF");
drop(frames);
assert_eq!(
inner.tabs.read().await.len(),
1,
"OOPIF must not be registered as a tab",
);
conn.shutdown();
}
#[tokio::test]
async fn tab_registrar_skips_oopif_when_parent_unknown() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
_extension_dirs: Vec::new(),
owns_process: false,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
debug_host_port: None,
ws_url: None,
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S_ORPHAN",
"targetInfo": {
"targetId": "F_NOWHERE",
"type": "iframe",
"url": "https://orphan.example.com/",
"attached": true,
},
"waitingForDebugger": true,
}),
)
.await;
let release_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Runtime.runIfWaitingForDebugger"),
)
.await
.expect("debugger-release did not fire within 2s");
mock.reply(release_id, json!({})).await;
assert_eq!(inner.tabs.read().await.len(), 1);
let parent_tab = inner.tabs.read().await.get("S1").cloned().unwrap();
assert!(parent_tab.inner.frames.read().await.is_empty());
conn.shutdown();
}
#[tokio::test]
async fn dispose_browser_context_sends_target_dispose() {
use zendriver_transport::testing::MockConnection;
let (mut mock, conn) = MockConnection::pair();
let inner = test_only_inner_from_conn(conn.clone());
let inner_for_task = inner.clone();
let fut =
tokio::spawn(async move { inner_for_task.dispose_browser_context("ctx-abc").await });
let id = mock.expect_cmd("Target.disposeBrowserContext").await;
assert_eq!(mock.last_sent()["params"]["browserContextId"], "ctx-abc");
assert!(mock.last_sent().get("sessionId").is_none());
mock.reply(id, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn create_browser_context_with_sends_correct_cdp() {
use zendriver_transport::testing::MockConnection;
let (mut mock, conn) = MockConnection::pair();
let browser = test_only_browser_from_conn(conn.clone());
let fut = tokio::spawn({
let b = browser.clone();
async move {
b.create_browser_context_with(
Some("http://user:pass@p.webshare.io:80"),
Some("<-loopback>"),
)
.await
}
});
let id = mock.expect_cmd("Target.createBrowserContext").await;
let sent = mock.last_sent();
assert_eq!(
sent["params"]["proxyServer"],
"http://user:pass@p.webshare.io:80"
);
assert_eq!(sent["params"]["proxyBypassList"], "<-loopback>");
mock.reply(id, json!({ "browserContextId": "ctx-new" }))
.await;
let ctx = fut.await.unwrap().unwrap();
assert_eq!(ctx.id(), "ctx-new");
conn.shutdown();
}
#[tokio::test]
async fn create_browser_context_without_proxy_omits_fields() {
use zendriver_transport::testing::MockConnection;
let (mut mock, conn) = MockConnection::pair();
let browser = test_only_browser_from_conn(conn.clone());
let fut = tokio::spawn({
let b = browser.clone();
async move { b.create_browser_context_with(None, None).await }
});
let id = mock.expect_cmd("Target.createBrowserContext").await;
let sent = mock.last_sent();
let proxy_server_field = sent["params"].get("proxyServer");
assert!(proxy_server_field.is_none() || proxy_server_field.unwrap().is_null());
let bypass_field = sent["params"].get("proxyBypassList");
assert!(bypass_field.is_none() || bypass_field.unwrap().is_null());
mock.reply(id, json!({ "browserContextId": "ctx-plain" }))
.await;
let ctx = fut.await.unwrap().unwrap();
assert_eq!(ctx.id(), "ctx-plain");
conn.shutdown();
}
#[tokio::test]
async fn grant_permissions_dispatches_with_mapped_strings_and_origin() {
use zendriver_transport::testing::MockConnection;
let (mut mock, conn) = MockConnection::pair();
let browser = test_only_browser_from_conn(conn.clone());
let fut = tokio::spawn({
let b = browser.clone();
async move {
b.grant_permissions(
&[
PermissionType::Geolocation,
PermissionType::VideoCapture,
PermissionType::ClipboardReadWrite,
],
Some("https://example.com"),
)
.await
}
});
let id = mock.expect_cmd("Browser.grantPermissions").await;
let sent = mock.last_sent();
let perms = sent["params"]["permissions"]
.as_array()
.expect("permissions must be an array");
assert_eq!(
perms,
&vec![
json!("geolocation"),
json!("videoCapture"),
json!("clipboardReadWrite"),
]
);
assert_eq!(sent["params"]["origin"], "https://example.com");
assert!(sent.get("sessionId").is_none());
mock.reply(id, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn grant_permissions_omits_origin_when_none() {
use zendriver_transport::testing::MockConnection;
let (mut mock, conn) = MockConnection::pair();
let browser = test_only_browser_from_conn(conn.clone());
let fut = tokio::spawn({
let b = browser.clone();
async move {
b.grant_permissions(&[PermissionType::Notifications], None)
.await
}
});
let id = mock.expect_cmd("Browser.grantPermissions").await;
let sent = mock.last_sent();
assert_eq!(
sent["params"]["permissions"].as_array().unwrap(),
&vec![json!("notifications")]
);
let origin_field = sent["params"].get("origin");
assert!(
origin_field.is_none() || origin_field.unwrap().is_null(),
"origin must be omitted when None"
);
mock.reply(id, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn reset_permissions_dispatches() {
use zendriver_transport::testing::MockConnection;
let (mut mock, conn) = MockConnection::pair();
let browser = test_only_browser_from_conn(conn.clone());
let fut = tokio::spawn({
let b = browser.clone();
async move { b.reset_permissions().await }
});
let id = mock.expect_cmd("Browser.resetPermissions").await;
assert!(mock.last_sent().get("sessionId").is_none());
mock.reply(id, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn grant_all_permissions_sends_full_list() {
use zendriver_transport::testing::MockConnection;
let (mut mock, conn) = MockConnection::pair();
let browser = test_only_browser_from_conn(conn.clone());
let fut = tokio::spawn({
let b = browser.clone();
async move { b.grant_all_permissions().await }
});
let id = mock.expect_cmd("Browser.grantPermissions").await;
let sent = mock.last_sent();
let perms = sent["params"]["permissions"]
.as_array()
.expect("permissions must be an array");
assert_eq!(perms.len(), PermissionType::ALL.len());
assert!(perms.contains(&json!("geolocation")));
assert!(perms.contains(&json!("midiSysex")));
let origin_field = sent["params"].get("origin");
assert!(origin_field.is_none() || origin_field.unwrap().is_null());
mock.reply(id, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[test]
fn permission_type_as_cdp_round_trips_known_strings() {
assert_eq!(PermissionType::Geolocation.as_cdp(), "geolocation");
assert_eq!(PermissionType::VideoCapture.as_cdp(), "videoCapture");
assert_eq!(PermissionType::AudioCapture.as_cdp(), "audioCapture");
assert_eq!(
PermissionType::ClipboardReadWrite.as_cdp(),
"clipboardReadWrite"
);
assert_eq!(PermissionType::MidiSysex.as_cdp(), "midiSysex");
}
#[test]
fn resolve_ws_from_http_parses_web_socket_debugger_url() {
let ws = "ws://127.0.0.1:9222/devtools/browser/abc";
let body = format!("{{\"webSocketDebuggerUrl\":\"{ws}\"}}");
assert_eq!(parse_ws_from_json_version(body.as_bytes()).unwrap(), ws);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{{\"Browser\":\"Chrome/120.0.0.0\",\"webSocketDebuggerUrl\":\"{ws}\"}}"
);
assert_eq!(parse_ws_from_json_version(resp.as_bytes()).unwrap(), ws);
let bad = b"HTTP/1.1 200 OK\r\n\r\n{\"Browser\":\"Chrome/120\"}";
assert!(matches!(
parse_ws_from_json_version(bad),
Err(ZendriverError::Browser(BrowserError::DevtoolsParse))
));
}
#[tokio::test]
async fn connect_ws_endpoint_does_not_spawn() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let fut = tokio::spawn(async move {
finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: None,
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: debug_host_port_from_ws(
"ws://127.0.0.1:9222/devtools/browser/abc",
),
ws_url: Some("ws://127.0.0.1:9222/devtools/browser/abc".to_string()),
owns_process: false,
})
.await
});
let id = mock.expect_cmd("Target.setAutoAttach").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["flatten"], true);
assert!(
sent.get("sessionId").is_none(),
"auto-attach is browser-scope"
);
mock.reply(id, json!({})).await;
let id = mock.expect_cmd("Target.getTargets").await;
mock.reply(
id,
json!({ "targetInfos": [{ "targetId": "T1", "type": "page", "url": "about:blank" }] }),
)
.await;
let id = mock.expect_cmd("Target.attachToTarget").await;
assert_eq!(mock.last_sent()["params"]["targetId"], "T1");
mock.reply(id, json!({ "sessionId": "S1" })).await;
let inner = fut.await.unwrap().unwrap();
assert!(!inner.owns_process, "connect path must not own the process");
assert!(
inner.child.lock().await.is_none(),
"connect path holds no Child handle",
);
assert_eq!(inner.tabs.read().await.len(), 1);
assert!(inner.tabs.read().await.contains_key("S1"));
inner.conn.shutdown();
}
#[tokio::test]
async fn connect_sets_owns_process_false_and_skips_kill() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let fut = tokio::spawn(async move {
finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: None,
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: None,
ws_url: None,
owns_process: false,
})
.await
});
let id = mock.expect_cmd("Target.setAutoAttach").await;
mock.reply(id, json!({})).await;
let id = mock.expect_cmd("Target.getTargets").await;
mock.reply(
id,
json!({ "targetInfos": [{ "targetId": "T1", "type": "page", "url": "about:blank" }] }),
)
.await;
let id = mock.expect_cmd("Target.attachToTarget").await;
mock.reply(id, json!({ "sessionId": "S1" })).await;
let inner = fut.await.unwrap().unwrap();
assert!(!inner.owns_process);
let browser = Browser { inner };
browser.close().await.unwrap();
}
#[test]
fn preference_accumulates_on_builder() {
let b = Browser::builder()
.preference("a.b", serde_json::json!(false))
.preference("c", serde_json::json!(1));
assert_eq!(b.preferences.len(), 2);
assert_eq!(b.preferences[0].0, "a.b");
}
}