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",
];
let local_app_data = std::env::var_os("LOCALAPPDATA")
.filter(|v| !v.is_empty())
.map(PathBuf::from);
match channel {
Channel::Chrome | Channel::Chromium | Channel::Auto => {
for p in chrome {
v.push(PathBuf::from(p));
}
if let Some(local) = &local_app_data {
v.push(local.join(r"Google\Chrome\Application\chrome.exe"));
}
}
Channel::Brave => {
for p in brave {
v.push(PathBuf::from(p));
}
if let Some(local) = &local_app_data {
v.push(local.join(r"BraveSoftware\Brave-Browser\Application\brave.exe"));
}
}
Channel::Edge => {
for p in edge {
v.push(PathBuf::from(p));
}
if let Some(local) = &local_app_data {
v.push(local.join(r"Microsoft\Edge\Application\msedge.exe"));
}
}
}
}
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
}
}
const DEVTOOLS_ACTIVE_PORT_FILE: &str = "DevToolsActivePort";
const DEVTOOLS_PORT_POLL_INTERVAL: Duration = Duration::from_millis(25);
pub(crate) fn parse_devtools_active_port(contents: &str) -> Option<String> {
let mut lines = contents.lines();
let port: u16 = lines.next()?.trim().parse().ok()?;
let path = lines.next()?.trim();
if !path.starts_with('/') {
return None;
}
Some(format!("ws://127.0.0.1:{port}{path}"))
}
pub(crate) async fn poll_devtools_active_port(user_data_dir: &Path) -> String {
let path = user_data_dir.join(DEVTOOLS_ACTIVE_PORT_FILE);
loop {
if let Ok(contents) = tokio::fs::read_to_string(&path).await {
if let Some(url) = parse_devtools_active_port(&contents) {
return url;
}
}
tokio::time::sleep(DEVTOOLS_PORT_POLL_INTERVAL).await;
}
}
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)>,
pub(crate) proxy: Option<crate::proxy::ParsedProxy>,
#[cfg(feature = "interception")]
pub(crate) proxy_auth: Option<(String, String)>,
#[cfg(feature = "geo")]
pub(crate) geo_resolver: Option<Arc<dyn zendriver_stealth::geo::GeoResolver>>,
#[cfg(feature = "tracker-blocking")]
pub(crate) block_trackers: bool,
#[cfg(feature = "tracker-blocking")]
pub(crate) tracker_blocklist_domains: Vec<String>,
#[cfg(feature = "tracker-blocking")]
pub(crate) tracker_blocklist_files: Vec<std::path::PathBuf>,
#[cfg(feature = "tracker-blocking")]
pub(crate) tracker_blocklist_urls: Vec<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)
.field(
"proxy",
&self.proxy.as_ref().map(|p| {
format!(
"ParsedProxy {{ server: {:?}, credentials: {} }}",
p.server,
if p.credentials.is_some() {
"Some(<redacted>)"
} else {
"None"
}
)
}),
);
#[cfg(feature = "interception")]
s.field(
"proxy_auth",
&self
.proxy_auth
.as_ref()
.map(|(u, _)| format!("Some({u:?}, <redacted>)"))
.unwrap_or_else(|| "None".into()),
);
#[cfg(feature = "geo")]
s.field(
"geo_resolver",
&if self.geo_resolver.is_some() {
"Some(<dyn GeoResolver>)"
} else {
"None"
},
);
#[cfg(feature = "tracker-blocking")]
s.field("block_trackers", &self.block_trackers)
.field("tracker_blocklist_domains", &self.tracker_blocklist_domains)
.field("tracker_blocklist_files", &self.tracker_blocklist_files)
.field("tracker_blocklist_urls", &self.tracker_blocklist_urls);
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 proxy(mut self, proxy: impl Into<String>) -> Self {
let raw = proxy.into();
match crate::proxy::split_proxy_url(&raw) {
Ok(parsed) => {
#[cfg(feature = "interception")]
{
if self.proxy_auth.is_none() {
if let Some((u, p)) = parsed.credentials.clone() {
self.proxy_auth = Some((u, p));
}
}
}
self.proxy = Some(parsed);
}
Err(e) => tracing::warn!(error = %e, "proxy: ignoring invalid proxy URL"),
}
self
}
#[cfg(feature = "tracker-blocking")]
#[must_use]
pub fn block_trackers(mut self, enable: bool) -> Self {
self.block_trackers = enable;
self
}
#[cfg(feature = "tracker-blocking")]
#[must_use]
pub fn tracker_blocklist_add(mut self, domains: impl IntoIterator<Item = String>) -> Self {
self.tracker_blocklist_domains.extend(domains);
self
}
#[cfg(feature = "tracker-blocking")]
#[must_use]
pub fn tracker_blocklist_file(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.tracker_blocklist_files.push(path.into());
self
}
#[cfg(feature = "tracker-blocking")]
#[must_use]
pub fn tracker_blocklist_url(mut self, url: impl Into<String>) -> Self {
self.tracker_blocklist_urls.push(url.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
}
#[cfg(feature = "geo")]
#[must_use]
pub fn geo_locale(mut self, country: impl TryInto<zendriver_stealth::geo::Country>) -> Self {
match country.try_into() {
Ok(c) => {
let derived = zendriver_stealth::geo::persona(c);
self.persona_overlay = Some(match self.persona_overlay.take() {
Some(existing) => derived.overlay(existing),
None => derived,
});
}
Err(_) => tracing::warn!("geo_locale: invalid country code; ignoring"),
}
self
}
#[cfg(feature = "geo")]
#[must_use]
pub fn geo_auto(mut self) -> Self {
let server = self.proxy.as_ref().map(|p| p.server.clone());
let auth = self.proxy.as_ref().and_then(|p| p.credentials.clone());
self.geo_resolver = Some(Arc::new(
crate::IpApiResolver::new().with_proxy(server, auth),
));
self
}
#[cfg(feature = "geo")]
#[must_use]
pub fn geo_resolver(
mut self,
resolver: impl zendriver_stealth::geo::GeoResolver + 'static,
) -> Self {
self.geo_resolver = Some(Arc::new(resolver));
self
}
#[cfg(feature = "geo")]
async fn apply_geo_overlay(&mut self) {
let Some(resolver) = self.geo_resolver.clone() else {
return;
};
let explicit_locale = self
.persona_overlay
.as_ref()
.is_some_and(|p| p.locale.is_some())
|| self.persona.as_ref().is_some_and(|p| p.locale.is_some());
if explicit_locale {
return;
}
if let Some(country) = resolver.country().await {
let derived = zendriver_stealth::geo::persona(country);
self.persona_overlay = Some(match self.persona_overlay.take() {
Some(existing) => derived.overlay(existing),
None => derived,
});
}
}
#[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());
}
if let Some(parsed) = self.proxy.as_ref() {
let explicit = self
.extra_args
.iter()
.any(|a| a.starts_with("--proxy-server="));
if !explicit {
v.push(format!("--proxy-server={}", parsed.server));
}
}
v.extend(self.extra_args.iter().cloned());
if !self.extra_args.iter().any(|a| !a.starts_with('-')) {
v.push("about:blank".to_string());
}
v
}
#[cfg(feature = "tracker-blocking")]
async fn build_tracker_matcher(
&self,
) -> Result<Option<std::sync::Arc<crate::HostMatcher>>, ZendriverError> {
let mut domains: Vec<String> = Vec::new();
if self.block_trackers {
domains.extend(crate::tracker::bundled_hosts());
}
domains.extend(self.tracker_blocklist_domains.iter().cloned());
for path in &self.tracker_blocklist_files {
let text = std::fs::read_to_string(path)?; domains.extend(crate::tracker::parse_blocklist(&text));
}
for url in &self.tracker_blocklist_urls {
let hosts = crate::tracker::load_or_download_blocklist(url).await?; domains.extend(hosts);
}
if domains.is_empty() {
return Ok(None);
}
Ok(Some(std::sync::Arc::new(crate::HostMatcher::new(domains))))
}
}
#[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>>,
#[cfg_attr(unix, allow(dead_code))]
pub(crate) job: ProcessJob,
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>,
#[cfg(feature = "interception")]
pub(crate) context_proxy_auth: tokio::sync::Mutex<HashMap<String, (String, String)>>,
#[cfg(feature = "tracker-blocking")]
pub(crate) tracker_matcher: Option<std::sync::Arc<crate::HostMatcher>>,
#[cfg(feature = "interception")]
pub(crate) session_intercept_handles:
tokio::sync::Mutex<HashMap<String, 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(())
}
pub(crate) async fn create_browser_context_raw(
&self,
proxy_server: Option<&str>,
bypass: Option<&str>,
) -> Result<String, 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) = bypass {
params.insert(
"proxyBypassList".into(),
serde_json::Value::String(b.to_string()),
);
}
let res = self
.conn
.call_raw(
"Target.createBrowserContext",
serde_json::Value::Object(params),
None,
)
.await?;
res.get("browserContextId")
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| {
ZendriverError::Navigation(
"Target.createBrowserContext returned no browserContextId".into(),
)
})
}
}
#[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),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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 {
debug!(
target_id = %session.target_info.target_id,
url = %session.target_info.url,
"tab-registrar: target attached before the browser weak ref was wired; skipping \
registration (expected exactly once, for the initial target)"
);
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://")
{
warn!(
url = %url,
target_id = %session.target_info.target_id,
"chrome opened an internal page on its own; not tracked as a tab \
(if a stray window is visible, this is it)",
);
return Ok(());
}
let conn = session.connection().clone();
let new_session = SessionHandle::new(conn, session.session_id.to_string());
#[cfg(feature = "interception")]
let new_session_for_intercept = new_session.clone();
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();
#[cfg(feature = "interception")]
{
let mut builder =
zendriver_interception::InterceptBuilder::new(&new_session_for_intercept);
let mut needs_actor = false;
#[cfg(feature = "tracker-blocking")]
if let Some(matcher) = browser.tracker_matcher.clone() {
builder = builder.block_hosts(matcher);
needs_actor = true;
}
if let Some(ctx_id) = session.target_info.browser_context_id.as_deref() {
let creds = browser.context_proxy_auth.lock().await.get(ctx_id).cloned();
if let Some((user, pass)) = creds {
builder = builder.handle_auth(user, pass);
needs_actor = true;
}
}
if needs_actor {
let handle = builder.start();
browser
.session_intercept_handles
.lock()
.await
.insert(new_session_for_intercept.session_id().to_string(), handle);
}
}
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;
}
#[cfg(feature = "interception")]
{
browser
.session_intercept_handles
.lock()
.await
.remove(session_id);
}
}
}
const WS_ENDPOINT_TIMEOUT: Duration = Duration::from_secs(15);
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
const BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(3);
const JSON_VERSION_TIMEOUT: Duration = Duration::from_secs(10);
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(windows)]
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
pub(crate) struct ProcessJob {
#[cfg(windows)]
job: std::sync::Mutex<Option<win32job::Job>>,
}
impl ProcessJob {
pub(crate) fn none() -> Self {
Self {
#[cfg(windows)]
job: std::sync::Mutex::new(None),
}
}
#[cfg(windows)]
pub(crate) fn confine(child: &Child) -> Self {
let Some(handle) = child.raw_handle() else {
warn!(
"chrome exited before it could be confined to a job object; \
falling back to single-process termination"
);
return Self::none();
};
match Self::try_confine(handle) {
Ok(job) => {
debug!("confined chrome to a job object; its process tree will die with it");
Self {
job: std::sync::Mutex::new(Some(job)),
}
}
Err(e) => {
warn!(
error = %e,
"could not confine chrome to a job object; falling back to \
single-process termination — chrome's renderer/GPU/utility \
processes may outlive close()"
);
Self::none()
}
}
}
#[cfg(windows)]
fn try_confine(
handle: std::os::windows::io::RawHandle,
) -> Result<win32job::Job, win32job::JobError> {
let mut info = win32job::ExtendedLimitInfo::new();
info.limit_kill_on_job_close();
let job = win32job::Job::create_with_limit_info(&info)?;
job.assign_process(handle as isize)?;
Ok(job)
}
#[cfg(not(windows))]
pub(crate) fn confine(_child: &Child) -> Self {
Self::none()
}
#[cfg(windows)]
pub(crate) fn kill_tree(&self) -> bool {
let taken = self
.job
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
match taken {
Some(job) => {
drop(job);
true
}
None => false,
}
}
#[cfg(not(windows))]
#[cfg_attr(unix, allow(dead_code))]
pub(crate) fn kill_tree(&self) -> bool {
false
}
}
impl std::fmt::Debug for ProcessJob {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[cfg(windows)]
let held = self
.job
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some();
#[cfg(not(windows))]
let held = false;
f.debug_struct("ProcessJob").field("held", &held).finish()
}
}
pub(crate) type ChildSlot = Arc<std::sync::Mutex<Option<Child>>>;
pub(crate) async fn guard_handshake<F>(
budget: Duration,
child_slot: &ChildSlot,
handshake: F,
) -> Result<Arc<BrowserInner>, ZendriverError>
where
F: std::future::Future<Output = Result<Arc<BrowserInner>, ZendriverError>>,
{
match timeout(budget, handshake).await {
Ok(res) => res,
Err(_elapsed) => {
let orphan = child_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
if let Some(mut child) = orphan {
warn!(
budget = ?budget,
"chrome CDP handshake exceeded its budget; killing the spawned child",
);
let _ = child.kill().await;
}
Err(BrowserError::HandshakeTimeout.into())
}
}
}
pub(crate) struct FinishConnect {
pub(crate) conn: Connection,
pub(crate) registrar: Arc<TabRegistrar>,
pub(crate) input_profile: zendriver_stealth::InputProfile,
pub(crate) child: ChildSlot,
pub(crate) job: ProcessJob,
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,
#[cfg(feature = "tracker-blocking")]
pub(crate) tracker_matcher: Option<std::sync::Arc<crate::HostMatcher>>,
}
pub(crate) async fn finish_connect(
args: FinishConnect,
) -> Result<Arc<BrowserInner>, ZendriverError> {
let FinishConnect {
conn,
registrar,
input_profile,
child,
job,
owned_tmp,
extension_dirs,
debug_host_port,
ws_url,
owns_process,
#[cfg(feature = "tracker-blocking")]
tracker_matcher,
} = 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 no_targets = Vec::new();
let targets = list["targetInfos"].as_array().unwrap_or(&no_targets);
let target_id = targets
.iter()
.find(|t| t["type"] == "page")
.or_else(|| targets.first())
.and_then(|t| t["targetId"].as_str())
.ok_or_else(|| ZendriverError::Navigation("no initial target found".into()))?
.to_string();
let strays: Vec<String> = if owns_process {
targets
.iter()
.filter(|t| t["type"] == "page")
.filter_map(|t| t["targetId"].as_str())
.filter(|id| *id != target_id)
.map(str::to_string)
.collect()
} else {
Vec::new()
};
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();
for stray in strays {
match conn
.call_raw("Target.closeTarget", json!({ "targetId": stray }), None)
.await
{
Ok(_) => debug!(target_id = %stray, "closed stray page target at connect"),
Err(e) => {
warn!(target_id = %stray, error = %e, "could not close stray page target");
}
}
}
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
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take(),
),
job,
_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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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()));
#[cfg(feature = "geo")]
self.apply_geo_overlay().await;
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 _ = std::fs::remove_file(user_data_path.join(DEVTOOLS_ACTIVE_PORT_FILE));
let mut cmd = Command::new(&exe);
cmd.args(&flags)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(windows)]
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
let mut child = cmd.spawn().map_err(BrowserError::SpawnFailed)?;
let job = ProcessJob::confine(&child);
let stderr = child.stderr.take().ok_or(BrowserError::DevtoolsParse)?;
let mut lines = BufReader::new(stderr).lines();
let ws_url = timeout(WS_ENDPOINT_TIMEOUT, async {
tokio::select! {
res = 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())
} => res,
url = poll_devtools_active_port(&user_data_path) => {
debug!(ws_url = %url, "resolved endpoint from DevToolsActivePort");
Ok(url)
}
}
})
.await
.map_err(|_| BrowserError::WsTimeout)??;
#[cfg(feature = "tracker-blocking")]
let tracker_matcher = self.build_tracker_matcher().await?;
let child_slot: ChildSlot = Arc::new(std::sync::Mutex::new(Some(child)));
let inner = guard_handshake(HANDSHAKE_TIMEOUT, &child_slot, async {
debug!(ws_url = %ws_url, "connecting to chrome");
let conn = zendriver_transport::connect_with_observers(&ws_url, observers).await?;
finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: child_slot.clone(),
job,
owned_tmp,
extension_dirs,
debug_host_port: debug_host_port_from_ws(&ws_url),
ws_url: Some(ws_url.clone()),
owns_process: true,
#[cfg(feature = "tracker-blocking")]
tracker_matcher,
})
.await
})
.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")]
{
let main_session = inner.main_tab.session().clone();
let mut builder = zendriver_interception::InterceptBuilder::new(&main_session);
let mut needs_actor = false;
if let Some((user, pass)) = self.proxy_auth.clone() {
builder = builder.handle_auth(user, pass);
needs_actor = true;
}
#[cfg(feature = "tracker-blocking")]
if let Some(matcher) = inner.tracker_matcher.clone() {
builder = builder.block_hosts(matcher);
needs_actor = true;
}
if needs_actor {
let handle = builder.start();
let _ = inner.proxy_auth_handle.set(handle);
}
}
Ok(Browser { inner })
}
#[cfg_attr(not(feature = "geo"), allow(unused_mut))]
pub async fn connect(mut 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());
};
#[cfg(feature = "geo")]
self.apply_geo_overlay().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: 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: ChildSlot::default(),
job: ProcessJob::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,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
})
.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()
}
#[cfg(test)]
pub(crate) fn test_only_from_inner(inner: std::sync::Arc<BrowserInner>) -> Self {
Browser { inner }
}
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 id = self
.inner
.create_browser_context_raw(proxy_server, proxy_bypass_list)
.await?;
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
}
#[must_use]
pub fn browser_context(&self) -> crate::BrowserContextBuilder {
crate::BrowserContextBuilder::new(self.inner.clone())
}
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.close_within(BROWSER_CLOSE_TIMEOUT).await
}
pub(crate) async fn close_within(
self,
browser_close_budget: Duration,
) -> Result<(), ZendriverError> {
if !self.inner.owns_process {
self.inner.conn.shutdown();
return Ok(());
}
let quit = timeout(
browser_close_budget,
self.inner.conn.call_raw("Browser.close", json!({}), None),
)
.await;
let acked = match quit {
Ok(Ok(_)) => true,
Ok(Err(zendriver_transport::CallError::Transport(_))) => true,
Ok(Err(e @ zendriver_transport::CallError::Timeout { .. })) => {
warn!(error = %e, "Browser.close went unanswered; falling back to signal shutdown");
false
}
Ok(Err(e)) => {
warn!(error = %e, "Browser.close was refused; falling back to signal shutdown");
false
}
Err(_elapsed) => {
warn!(
budget = ?browser_close_budget,
"Browser.close went unanswered; falling back to signal shutdown",
);
false
}
};
self.inner.conn.shutdown();
let mut child_guard = self.inner.child.lock().await;
if let Some(mut child) = child_guard.take() {
if acked {
if let Ok(Ok(_status)) = timeout(SHUTDOWN_GRACE, child.wait()).await {
return Ok(());
}
warn!("chrome accepted Browser.close but did not exit; hard-killing");
}
#[cfg(unix)]
{
if let Some(pid) = child.id() {
#[allow(unsafe_code)]
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
}
#[cfg(not(unix))]
{
if self.inner.job.kill_tree() {
debug!("closed chrome's job object; its whole process tree is terminating");
}
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 a_job_that_confines_nothing_reports_no_kill_and_is_idempotent() {
let job = ProcessJob::none();
assert!(
!job.kill_tree(),
"an empty ProcessJob must report false so close() falls back to the single-PID kill",
);
assert!(!job.kill_tree(), "kill_tree must be idempotent");
}
#[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());
}
#[cfg(feature = "geo")]
#[test]
fn geo_locale_sets_overlay() {
let builder = Browser::builder().geo_locale("US");
let p = builder.resolved_persona();
assert_eq!(p.locale.as_deref(), Some("en-US"));
}
#[cfg(feature = "geo")]
#[test]
fn geo_locale_preserves_explicit_timezone() {
let overlay = Persona::builder().timezone("Asia/Tokyo").build();
let builder = Browser::builder().persona_overlay(overlay).geo_locale("US");
let p = builder.resolved_persona();
assert_eq!(
p.timezone.as_deref(),
Some("Asia/Tokyo"),
"explicit timezone must win over the geo-derived one"
);
assert_eq!(p.locale.as_deref(), Some("en-US"));
}
#[cfg(feature = "geo")]
struct StubCountryResolver {
cc: &'static str,
calls: Arc<std::sync::atomic::AtomicUsize>,
}
#[cfg(feature = "geo")]
#[async_trait::async_trait]
impl zendriver_stealth::geo::GeoResolver for StubCountryResolver {
async fn country(&self) -> Option<zendriver_stealth::geo::Country> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
zendriver_stealth::geo::Country::try_from(self.cc).ok()
}
}
#[cfg(feature = "geo")]
#[tokio::test]
async fn geo_resolver_augments_persona_at_launch() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut builder = Browser::builder().geo_resolver(StubCountryResolver {
cc: "DE",
calls: calls.clone(),
});
builder.apply_geo_overlay().await;
let p = builder.resolved_persona();
assert_eq!(p.locale.as_deref(), Some("de-DE"));
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[cfg(feature = "geo")]
#[tokio::test]
async fn explicit_geo_locale_wins_and_skips_probe() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut builder = Browser::builder()
.geo_locale("US")
.geo_resolver(StubCountryResolver {
cc: "DE",
calls: calls.clone(),
});
builder.apply_geo_overlay().await;
let p = builder.resolved_persona();
assert_eq!(p.locale.as_deref(), Some("en-US"));
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"explicit locale must skip the geo probe entirely"
);
}
#[cfg(feature = "geo")]
#[tokio::test]
async fn base_persona_locale_wins_and_skips_probe() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut builder = Browser::builder()
.persona(Persona::builder().locale("ja-JP").build())
.geo_resolver(StubCountryResolver {
cc: "DE",
calls: calls.clone(),
});
builder.apply_geo_overlay().await;
let p = builder.resolved_persona();
assert_eq!(p.locale.as_deref(), Some("ja-JP"));
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"a locale-bearing base persona must skip the geo probe entirely"
);
}
#[cfg(feature = "geo")]
#[tokio::test]
async fn failed_probe_leaves_existing_overlay_untouched() {
struct NoneResolver;
#[async_trait::async_trait]
impl zendriver_stealth::geo::GeoResolver for NoneResolver {
async fn country(&self) -> Option<zendriver_stealth::geo::Country> {
None
}
}
let overlay = Persona::builder().timezone("UTC").build();
let mut builder = Browser::builder()
.persona_overlay(overlay)
.geo_resolver(NoneResolver);
builder.apply_geo_overlay().await;
let p = builder.resolved_persona();
assert_eq!(p.timezone.as_deref(), Some("UTC"));
assert_eq!(p.locale, None);
}
#[cfg(feature = "geo")]
#[tokio::test]
async fn geo_overlay_merges_with_existing_overlay_fields() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let overlay = Persona::builder().device_memory_gb(16).build();
let mut builder =
Browser::builder()
.persona_overlay(overlay)
.geo_resolver(StubCountryResolver {
cc: "DE",
calls: calls.clone(),
});
builder.apply_geo_overlay().await;
let p = builder.resolved_persona();
assert_eq!(p.device_memory_gb, Some(16));
assert_eq!(p.locale.as_deref(), Some("de-DE"));
assert!(p.languages.is_some());
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[cfg(feature = "geo")]
#[tokio::test]
async fn geo_overlay_preserves_explicit_timezone_while_filling_locale() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let overlay = Persona::builder().timezone("Asia/Tokyo").build();
let mut builder =
Browser::builder()
.persona_overlay(overlay)
.geo_resolver(StubCountryResolver {
cc: "DE",
calls: calls.clone(),
});
builder.apply_geo_overlay().await;
let p = builder.resolved_persona();
assert_eq!(
p.timezone.as_deref(),
Some("Asia/Tokyo"),
"explicit timezone must survive the geo-derived overlay merge"
);
assert_eq!(p.locale.as_deref(), Some("de-DE"));
assert!(p.languages.is_some());
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[cfg(feature = "geo")]
#[tokio::test]
async fn geo_auto_mirrors_proxy_and_credentials_into_resolver() {
let proxy = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::header_exists("Proxy-Authorization"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_string(r#"{"countryCode":"DE"}"#),
)
.mount(&proxy)
.await;
let builder = Browser::builder()
.proxy(format!("http://bob:s3cret@{}", proxy.address()))
.geo_auto();
let resolver = builder
.geo_resolver
.clone()
.expect("geo_auto sets a resolver");
assert_eq!(
resolver.country().await,
Some(zendriver_stealth::geo::Country::try_from("DE").unwrap())
);
}
#[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 proxy_stores_parsed_and_strips_userinfo_arg() {
let b = Browser::builder().proxy("http://bob:pw@host:3128");
let p = b.proxy.as_ref().unwrap();
assert_eq!(p.server, "http://host:3128");
assert_eq!(p.credentials, Some(("bob".into(), "pw".into())));
#[cfg(feature = "interception")]
assert_eq!(b.proxy_auth, Some(("bob".into(), "pw".into())));
let flags = b.build_flags(Path::new("/tmp/x"));
assert!(flags.contains(&"--proxy-server=http://host:3128".to_string()));
}
#[test]
fn build_flags_appends_about_blank_start_page() {
let flags = BrowserBuilder::new().build_flags(Path::new("/tmp/x"));
assert_eq!(
flags.last().map(String::as_str),
Some("about:blank"),
"about:blank must be the final positional arg in {flags:?}"
);
assert_eq!(
flags.iter().filter(|f| f.as_str() == "about:blank").count(),
1,
"exactly one start page expected in {flags:?}"
);
}
#[test]
fn build_flags_user_positional_url_suppresses_about_blank() {
let flags = BrowserBuilder::new()
.arg("https://example.com")
.build_flags(Path::new("/tmp/x"));
assert!(
!flags.contains(&"about:blank".to_string()),
"explicit positional URL should suppress about:blank in {flags:?}"
);
assert_eq!(
flags.last().map(String::as_str),
Some("https://example.com"),
"explicit start URL should remain the final arg in {flags:?}"
);
}
#[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:?}"
);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_chrome_candidates_include_the_per_user_install_after_program_files() {
let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) else {
return;
};
let local = PathBuf::from(local);
let paths = candidate_paths_for_channel(Channel::Chrome);
let per_user = local.join(r"Google\Chrome\Application\chrome.exe");
let per_user_at = paths.iter().position(|p| *p == per_user);
assert!(
per_user_at.is_some(),
"the per-user %LOCALAPPDATA% chrome.exe must be a candidate: {paths:?}",
);
let program_files_at = paths
.iter()
.position(|p| p.to_string_lossy().contains(r"Program Files\Google"));
if let (Some(system), Some(user)) = (program_files_at, per_user_at) {
assert!(
system < user,
"machine-wide Program Files must be tried before the per-user \
install, or a box with both would change which binary it \
launches: {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),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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();
}
#[cfg(feature = "interception")]
#[tokio::test]
async fn tab_registrar_installs_context_proxy_auth() {
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());
let mut auth = HashMap::new();
auth.insert(
"CTX1".to_string(),
("bob".to_string(), "s3cret".to_string()),
);
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(auth),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::new()),
}
});
registrar.set_browser(Arc::downgrade(&inner));
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S2",
"targetInfo": {
"targetId": "T2",
"type": "page",
"url": "about:blank",
"attached": true,
"browserContextId": "CTX1",
},
"waitingForDebugger": true,
}),
)
.await;
let enable_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Fetch.enable"),
)
.await
.expect("Fetch.enable not sent");
let enable = mock.last_sent();
assert_eq!(enable["params"]["handleAuthRequests"], true);
mock.reply(enable_id, json!({})).await;
mock.emit_event_for_session(
"Fetch.authRequired",
json!({
"requestId": "R1",
"authChallenge": { "source": "Proxy", "origin": "http://proxy", "scheme": "basic", "realm": "" },
}),
"S2",
)
.await;
let auth_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Fetch.continueWithAuth"),
)
.await
.expect("Fetch.continueWithAuth not sent");
let sent = mock.last_sent();
assert_eq!(
sent["params"]["authChallengeResponse"]["response"],
"ProvideCredentials"
);
assert_eq!(sent["params"]["authChallengeResponse"]["username"], "bob");
assert_eq!(
sent["params"]["authChallengeResponse"]["password"],
"s3cret"
);
mock.reply(auth_id, json!({})).await;
conn.shutdown();
}
#[cfg(all(test, feature = "tracker-blocking"))]
#[tokio::test]
async fn tab_registrar_chains_tracker_and_auth_into_one_actor() {
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());
let mut auth = HashMap::new();
auth.insert(
"CTX1".to_string(),
("bob".to_string(), "s3cret".to_string()),
);
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(auth),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: Some(std::sync::Arc::new(crate::HostMatcher::new([
"evil.example".to_string(),
]))),
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::new()),
}
});
registrar.set_browser(Arc::downgrade(&inner));
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S2",
"targetInfo": {
"targetId": "T2",
"type": "page",
"url": "about:blank",
"attached": true,
"browserContextId": "CTX1",
},
"waitingForDebugger": true,
}),
)
.await;
let enable_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Fetch.enable"),
)
.await
.expect("Fetch.enable not sent");
mock.reply(enable_id, json!({})).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
if let Some((method, _id)) = mock.try_recv_cmd() {
panic!("expected no further CDP calls after the single install, got `{method}`");
}
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),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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),
job: ProcessJob::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(),
#[cfg(feature = "interception")]
context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
#[cfg(feature = "interception")]
session_intercept_handles: tokio::sync::Mutex::new(HashMap::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: ChildSlot::default(),
job: ProcessJob::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,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
})
.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: ChildSlot::default(),
job: ProcessJob::none(),
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: None,
ws_url: None,
owns_process: false,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
})
.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();
}
fn never_answering_handshake(
child: ChildSlot,
) -> (
zendriver_transport::testing::MockConnection,
impl std::future::Future<Output = Result<Arc<BrowserInner>, ZendriverError>>,
) {
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 fut = finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child,
job: ProcessJob::none(),
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: None,
ws_url: None,
owns_process: true,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
});
(mock, fut)
}
#[tokio::test]
async fn handshake_timeout_reports_handshake_timeout_within_budget() {
let slot: ChildSlot = ChildSlot::default();
let (_mock, fut) = never_answering_handshake(slot.clone());
let budget = Duration::from_millis(150);
let started = std::time::Instant::now();
let err = guard_handshake(budget, &slot, fut).await.unwrap_err();
assert!(
matches!(err, ZendriverError::Browser(BrowserError::HandshakeTimeout)),
"expected HandshakeTimeout, got {err:?}",
);
assert!(
started.elapsed() < budget * 20,
"must fail inside the budget, took {:?}",
started.elapsed(),
);
}
#[cfg(unix)]
#[tokio::test]
async fn handshake_timeout_leaves_no_orphan_child() {
let mut cmd = Command::new("sleep");
cmd.arg("300")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
let child = cmd.spawn().expect("spawn stand-in child");
let pid = child.id().expect("child has a pid") as i32;
#[allow(unsafe_code)]
let alive_before = unsafe { libc::kill(pid, 0) } == 0;
assert!(alive_before, "stand-in child should be alive pre-handshake");
let slot: ChildSlot = Arc::new(std::sync::Mutex::new(Some(child)));
let (_mock, fut) = never_answering_handshake(slot.clone());
let err = guard_handshake(Duration::from_millis(150), &slot, fut)
.await
.unwrap_err();
assert!(matches!(
err,
ZendriverError::Browser(BrowserError::HandshakeTimeout)
));
assert!(
slot.lock().unwrap().is_none(),
"guard must drain the child slot",
);
#[allow(unsafe_code)]
let alive_after = unsafe { libc::kill(pid, 0) } == 0;
assert!(
!alive_after,
"handshake timeout must leave no orphan chrome process (pid {pid} still alive)",
);
}
#[tokio::test]
async fn guard_handshake_passes_through_a_successful_handshake() {
let slot: ChildSlot = ChildSlot::default();
let (mut mock, fut) = never_answering_handshake(slot.clone());
let guarded = tokio::spawn({
let slot = slot.clone();
async move { guard_handshake(Duration::from_secs(30), &slot, fut).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 = guarded.await.unwrap().expect("handshake should succeed");
assert!(inner.tabs.read().await.contains_key("S1"));
inner.conn.shutdown();
}
#[cfg(unix)]
async fn owning_browser_with_child(
child: Option<Child>,
) -> (zendriver_transport::testing::MockConnection, Browser) {
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(finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: Arc::new(std::sync::Mutex::new(child)),
job: ProcessJob::none(),
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: None,
ws_url: None,
owns_process: true,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
}));
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();
(mock, Browser { inner })
}
#[cfg(unix)]
fn spawn_stand_in_chrome() -> (Child, i32) {
let mut cmd = Command::new("sleep");
cmd.arg("300")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
let child = cmd.spawn().expect("spawn stand-in child");
let pid = child.id().expect("child has a pid") as i32;
(child, pid)
}
#[cfg(unix)]
fn pid_alive(pid: i32) -> bool {
#[allow(unsafe_code)]
unsafe {
libc::kill(pid, 0) == 0
}
}
#[cfg(unix)]
#[tokio::test]
async fn close_sends_browser_close_before_any_process_kill() {
let (child, pid) = spawn_stand_in_chrome();
let (mut mock, browser) = owning_browser_with_child(Some(child)).await;
let closing = tokio::spawn(async move { browser.close().await });
let id = mock.expect_cmd("Browser.close").await;
assert!(
mock.last_sent().get("sessionId").is_none(),
"Browser.close is browser-scope, not session-scope",
);
assert!(
pid_alive(pid),
"Browser.close must be sent before the process is killed",
);
mock.reply(id, json!({})).await;
closing.await.unwrap().expect("close should succeed");
assert!(!pid_alive(pid), "close() must leave no surviving process");
}
#[cfg(unix)]
#[tokio::test]
async fn close_hard_kills_when_browser_close_times_out() {
let (child, pid) = spawn_stand_in_chrome();
let (mut mock, browser) = owning_browser_with_child(Some(child)).await;
let closing =
tokio::spawn(async move { browser.close_within(Duration::from_millis(150)).await });
let _id = mock.expect_cmd("Browser.close").await;
closing
.await
.unwrap()
.expect("close must succeed via the fallback");
assert!(
!pid_alive(pid),
"a Browser.close timeout must still hard-kill the process (pid {pid} alive)",
);
drop(mock);
}
#[tokio::test]
async fn close_never_sends_browser_close_on_a_non_owning_browser() {
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(finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: ChildSlot::default(),
job: ProcessJob::none(),
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: None,
ws_url: None,
owns_process: false,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
}));
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();
Browser { inner }.close().await.unwrap();
let mut sent = Vec::new();
while let Some((method, _id)) = mock.try_recv_cmd() {
sent.push(method);
}
assert!(
!sent.iter().any(|m| m == "Browser.close"),
"close() on an attached browser must never quit the user's Chrome; sent: {sent:?}",
);
}
#[tokio::test]
async fn finish_connect_attaches_one_page_and_closes_every_other() {
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(finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: ChildSlot::default(),
job: ProcessJob::none(),
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: None,
ws_url: None,
owns_process: true,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
}));
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" },
{ "targetId": "T2", "type": "page", "url": "chrome://newtab/" },
] }),
)
.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 id = mock.expect_cmd("Target.closeTarget").await;
assert_eq!(
mock.last_sent()["params"]["targetId"],
"T2",
"the non-chosen page target must be swept",
);
mock.reply(id, json!({ "success": true })).await;
let inner = fut.await.unwrap().unwrap();
assert!(inner.tabs.read().await.contains_key("S1"));
let mut attaches = 0;
let mut closes = 0;
while let Some((method, _)) = mock.try_recv_cmd() {
match method.as_str() {
"Target.attachToTarget" => attaches += 1,
"Target.closeTarget" => closes += 1,
_ => {}
}
}
assert_eq!(attaches, 0, "no attach beyond the chosen target");
assert_eq!(closes, 0, "no close beyond the single extra page");
inner.conn.shutdown();
}
#[tokio::test]
async fn finish_connect_sweeps_nothing_when_there_is_one_page() {
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(finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: ChildSlot::default(),
job: ProcessJob::none(),
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: None,
ws_url: None,
owns_process: true,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
}));
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" },
{ "targetId": "W1", "type": "service_worker", "url": "https://x.test/sw.js" },
] }),
)
.await;
let id = mock.expect_cmd("Target.attachToTarget").await;
mock.reply(id, json!({ "sessionId": "S1" })).await;
let inner = timeout(Duration::from_secs(5), fut)
.await
.expect("finish_connect must not block sweeping a non-page target")
.unwrap()
.unwrap();
let mut closes = Vec::new();
while let Some((method, _)) = mock.try_recv_cmd() {
if method == "Target.closeTarget" {
closes.push(mock.last_sent()["params"]["targetId"].clone());
}
}
assert!(
closes.is_empty(),
"a single page (plus a worker) must trigger no sweep; closed: {closes:?}",
);
inner.conn.shutdown();
}
#[tokio::test]
async fn finish_connect_never_sweeps_tabs_of_a_browser_it_did_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(finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: ChildSlot::default(),
job: ProcessJob::none(),
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: None,
ws_url: None,
owns_process: false,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
}));
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": "https://mail.test/" },
{ "targetId": "T2", "type": "page", "url": "https://docs.test/" },
{ "targetId": "T3", "type": "page", "url": "https://news.test/" },
] }),
)
.await;
let id = mock.expect_cmd("Target.attachToTarget").await;
mock.reply(id, json!({ "sessionId": "S1" })).await;
let inner = timeout(Duration::from_secs(5), fut)
.await
.expect("finish_connect must not block sweeping tabs it does not own")
.unwrap()
.unwrap();
let mut closes = Vec::new();
while let Some((method, _)) = mock.try_recv_cmd() {
if method == "Target.closeTarget" {
closes.push(mock.last_sent()["params"]["targetId"].clone());
}
}
assert!(
closes.is_empty(),
"connect() must not close the user's tabs; closed: {closes:?}",
);
inner.conn.shutdown();
}
#[tokio::test]
async fn finish_connect_errors_cleanly_on_empty_get_targets() {
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(finish_connect(FinishConnect {
conn,
registrar,
input_profile,
child: ChildSlot::default(),
job: ProcessJob::none(),
owned_tmp: None,
extension_dirs: Vec::new(),
debug_host_port: None,
ws_url: None,
owns_process: true,
#[cfg(feature = "tracker-blocking")]
tracker_matcher: None,
}));
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": [] })).await;
let err = fut.await.unwrap().unwrap_err();
assert!(
matches!(err, ZendriverError::Navigation(ref m) if m.contains("no initial target")),
"expected a clean Navigation error, got {err:?}",
);
}
#[test]
fn parse_devtools_active_port_builds_a_ws_url() {
assert_eq!(
parse_devtools_active_port("54321\n/devtools/browser/abc-def-123\n").as_deref(),
Some("ws://127.0.0.1:54321/devtools/browser/abc-def-123"),
);
assert_eq!(
parse_devtools_active_port("9222\n/devtools/browser/x").as_deref(),
Some("ws://127.0.0.1:9222/devtools/browser/x"),
);
}
#[test]
fn parse_devtools_active_port_rejects_partial_or_malformed_files() {
for bad in [
"", "54321", "54321\n", "\n/devtools/browser/x", "notaport\n/devtools/browser/x", "54321\ndevtools/browser/x", "99999999\n/devtools/browser/x", ] {
assert!(
parse_devtools_active_port(bad).is_none(),
"must reject {bad:?}",
);
}
}
#[tokio::test]
async fn poll_devtools_active_port_waits_for_the_file_then_resolves() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
let poll = tokio::spawn(async move { poll_devtools_active_port(&path).await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!poll.is_finished(), "must wait while the file is absent");
std::fs::write(
dir.path().join("DevToolsActivePort"),
"45678\n/devtools/browser/from-file\n",
)
.unwrap();
let url = timeout(Duration::from_secs(5), poll)
.await
.expect("poll should resolve once the file lands")
.unwrap();
assert_eq!(url, "ws://127.0.0.1:45678/devtools/browser/from-file");
}
#[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");
}
#[cfg(feature = "tracker-blocking")]
#[tokio::test]
async fn tracker_sources_accumulate_and_build_a_matcher() {
let b = Browser::builder()
.tracker_blocklist_add(["custom-tracker.test".to_string()])
.tracker_blocklist_add(["another.test".to_string()]);
let matcher = b
.build_tracker_matcher()
.await
.unwrap()
.expect("matcher built");
assert!(matcher.is_blocked("custom-tracker.test"));
assert!(matcher.is_blocked("sub.another.test"));
assert!(!matcher.is_blocked("not-listed.test"));
let none = Browser::builder().build_tracker_matcher().await.unwrap();
assert!(none.is_none());
let bundled = Browser::builder()
.block_trackers(true)
.build_tracker_matcher()
.await
.unwrap()
.expect("bundled matcher");
assert!(bundled.is_blocked("doubleclick.net"));
}
}