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::{StealthObserver, StealthProfile};
use zendriver_transport::{
Connection, ObserverError, PausedSession, SessionHandle, TargetObserver,
};
use crate::error::{BrowserError, ZendriverError};
use crate::input::InputController;
use crate::tab::Tab;
pub fn find_chrome_executable() -> Result<PathBuf, BrowserError> {
let candidates = candidate_paths();
for c in &candidates {
if c.exists() {
return Ok(c.clone());
}
}
Err(BrowserError::ExecutableNotFound {
searched: candidates,
})
}
fn candidate_paths() -> Vec<PathBuf> {
let mut v = Vec::new();
for name in [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"chrome",
] {
if let Some(p) = which_on_path(name) {
v.push(p);
}
}
#[cfg(target_os = "macos")]
{
v.push(PathBuf::from(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
));
v.push(PathBuf::from(
"/Applications/Chromium.app/Contents/MacOS/Chromium",
));
}
#[cfg(target_os = "linux")]
{
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")]
{
v.push(PathBuf::from(
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
));
v.push(PathBuf::from(
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.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 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
}
}
#[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) extra_args: Vec<String>,
pub(crate) stealth: Option<StealthProfile>,
pub(crate) extra_observers: Vec<Arc<dyn TargetObserver>>,
#[cfg(feature = "interception")]
pub(crate) proxy_auth: Option<(String, String)>,
}
impl std::fmt::Debug for BrowserBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("BrowserBuilder");
s.field("headless", &self.headless)
.field("executable", &self.executable)
.field("user_data_dir", &self.user_data_dir)
.field("downloads_dir", &self.downloads_dir)
.field("extra_args", &self.extra_args)
.field("stealth", &self.stealth)
.field(
"extra_observers",
&format_args!("<{} observers>", self.extra_observers.len()),
);
#[cfg(feature = "interception")]
s.field(
"proxy_auth",
&self
.proxy_auth
.as_ref()
.map(|(u, _)| format!("Some({u:?}, <redacted>)"))
.unwrap_or_else(|| "None".into()),
);
s.finish()
}
}
impl BrowserBuilder {
#[must_use]
pub fn new() -> Self {
Self {
stealth: Some(StealthProfile::native()),
..Self::default()
}
}
#[must_use]
pub fn headless(mut self, on: bool) -> Self {
self.headless = Some(on);
self
}
#[must_use]
pub fn executable(mut self, path: impl Into<PathBuf>) -> Self {
self.executable = Some(path.into());
self
}
#[must_use]
pub fn user_data_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.user_data_dir = Some(path.into());
self
}
#[cfg(feature = "interception")]
#[must_use]
pub fn proxy_auth(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
self.proxy_auth = Some((user.into(), pass.into()));
self
}
#[must_use]
pub fn downloads_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.downloads_dir = Some(path.into());
self
}
#[must_use]
pub fn 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 stealth(mut self, profile: StealthProfile) -> Self {
self.stealth = Some(profile);
self
}
#[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());
v.push(
"--disable-features=PasswordManagerOnboarding,AutofillServerCommunication".to_string(),
);
if self.headless.unwrap_or(true) {
v.push("--headless=new".to_string());
v.push("--disable-gpu".to_string());
}
v.extend(self.extra_args.iter().cloned());
v
}
}
#[derive(Clone, Debug)]
pub struct Browser {
pub(crate) inner: Arc<BrowserInner>,
}
#[derive(Debug)]
pub(crate) struct BrowserInner {
pub(crate) conn: Connection,
pub(crate) main_tab: Tab,
pub(crate) child: tokio::sync::Mutex<Option<Child>>,
pub(crate) _user_data: Option<TempDir>,
#[allow(dead_code)]
pub(crate) stealth_input_profile: zendriver_stealth::InputProfile,
pub(crate) tabs: tokio::sync::RwLock<HashMap<String, Tab>>,
pub(crate) tabs_changed: tokio::sync::Notify,
#[cfg(feature = "interception")]
#[allow(dead_code)]
pub(crate) proxy_auth_handle: std::sync::OnceLock<zendriver_interception::InterceptHandle>,
}
pub(crate) struct TabRegistrar {
browser: OnceLock<Weak<BrowserInner>>,
input_profile: zendriver_stealth::InputProfile,
}
impl TabRegistrar {
fn new(input_profile: zendriver_stealth::InputProfile) -> Self {
Self {
browser: OnceLock::new(),
input_profile,
}
}
fn set_browser(&self, browser: Weak<BrowserInner>) {
let _ = self.browser.set(browser);
}
}
#[async_trait::async_trait]
impl TargetObserver for TabRegistrar {
fn name(&self) -> &'static str {
"tab-registrar"
}
async fn on_target_attached(&self, session: PausedSession<'_>) -> Result<(), ObserverError> {
let Some(weak) = self.browser.get() else {
warn!("TabRegistrar fired before browser weak ref was wired; skipping");
return Ok(());
};
let Some(browser) = weak.upgrade() else {
return Ok(());
};
match session.target_info.kind.as_str() {
"iframe" => {
let conn = session.connection().clone();
let new_session = SessionHandle::new(conn, session.session_id.to_string());
crate::frame::oopif::register_oopif_frame(
&browser,
session.target_info,
new_session,
)
.await;
Ok(())
}
"page" => {
let url = &session.target_info.url;
if url.starts_with("chrome://")
|| url.starts_with("devtools://")
|| url.starts_with("chrome-extension://")
|| url.starts_with("chrome-untrusted://")
{
return Ok(());
}
let conn = session.connection().clone();
let new_session = SessionHandle::new(conn, session.session_id.to_string());
let input = InputController::new(self.input_profile.clone());
let weak_inner = Arc::downgrade(&browser);
let tab = Tab::new(
new_session,
weak_inner,
input,
session.target_info.target_id.clone(),
);
let target_id_str = session.target_info.target_id.clone();
let mut tabs = browser.tabs.write().await;
tabs.retain(|_, t| t.target_id() != target_id_str);
tabs.insert(session.session_id.to_string(), tab);
drop(tabs);
browser.tabs_changed.notify_waiters();
Ok(())
}
_ => {
Ok(())
}
}
}
async fn on_target_detached(&self, session_id: &str) {
let Some(weak) = self.browser.get() else {
return;
};
let Some(browser) = weak.upgrade() else {
return;
};
let removed_tab = browser.tabs.write().await.remove(session_id).is_some();
if removed_tab {
browser.tabs_changed.notify_waiters();
} else {
let _ = crate::frame::oopif::deregister_oopif_frame(&browser, session_id).await;
}
}
}
const WS_ENDPOINT_TIMEOUT: Duration = Duration::from_secs(15);
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
impl BrowserBuilder {
pub async fn launch(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()?,
},
};
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 (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::new(profile.clone(), fp));
let mut obs_vec = Vec::with_capacity(2 + 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(1 + 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())
};
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))
}
};
let mut flags = self.build_flags(&user_data_path);
flags.extend(extra_flags);
if std::env::var("CI").is_ok() {
for needed in ["--no-sandbox", "--disable-dev-shm-usage"] {
if !flags.iter().any(|f| f == needed) {
flags.push(needed.into());
}
}
}
info!(executable = %exe.display(), "launching chrome");
let mut cmd = Command::new(&exe);
cmd.args(&flags)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(BrowserError::SpawnFailed)?;
let stderr = child.stderr.take().ok_or(BrowserError::DevtoolsParse)?;
let mut lines = BufReader::new(stderr).lines();
let ws_url = timeout(WS_ENDPOINT_TIMEOUT, async {
while let Ok(Some(line)) = lines.next_line().await {
debug!(line = %line, "chrome stderr");
if let Some(url) = parse_devtools_line(&line) {
return Ok::<String, ZendriverError>(url);
}
}
Err(BrowserError::DevtoolsParse.into())
})
.await
.map_err(|_| BrowserError::WsTimeout)??;
debug!(ws_url = %ws_url, "connecting to chrome");
let conn = zendriver_transport::connect_with_observers(&ws_url, observers).await?;
conn.call_raw(
"Target.setAutoAttach",
json!({
"autoAttach": true,
"waitForDebuggerOnStart": true,
"flatten": true,
}),
None,
)
.await?;
let list = conn.call_raw("Target.getTargets", json!({}), None).await?;
let target_id = list["targetInfos"]
.as_array()
.and_then(|arr| {
arr.iter()
.find(|t| t["type"] == "page")
.or_else(|| arr.first())
})
.and_then(|t| t["targetId"].as_str())
.ok_or_else(|| ZendriverError::Navigation("no initial target found".into()))?
.to_string();
let attach = conn
.call_raw(
"Target.attachToTarget",
json!({ "targetId": target_id, "flatten": true }),
None,
)
.await?;
let session_id = attach["sessionId"]
.as_str()
.ok_or_else(|| ZendriverError::Navigation("attach returned no sessionId".into()))?
.to_string();
let session_id_for_registry = session_id.clone();
let target_id_for_main_tab = target_id.clone();
let inner = Arc::new_cyclic(|weak: &std::sync::Weak<BrowserInner>| {
let session = SessionHandle::new(conn.clone(), session_id);
let main_tab_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(
session,
weak.clone(),
main_tab_input,
target_id_for_main_tab,
);
BrowserInner {
conn,
main_tab,
child: tokio::sync::Mutex::new(Some(child)),
_user_data: owned_tmp,
stealth_input_profile: input_profile,
tabs: tokio::sync::RwLock::new(HashMap::new()),
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
inner
.tabs
.write()
.await
.insert(session_id_for_registry, inner.main_tab.clone());
if let Some(dir) = self.downloads_dir.as_ref() {
inner
.conn
.call_raw(
"Browser.setDownloadBehavior",
json!({
"behavior": "allow",
"downloadPath": dir.display().to_string(),
"eventsEnabled": true,
}),
None,
)
.await?;
}
#[cfg(feature = "interception")]
if let Some((user, pass)) = self.proxy_auth.clone() {
let main_session = inner.main_tab.session().clone();
let handle = zendriver_interception::InterceptBuilder::new(&main_session)
.handle_auth(user, pass)
.start();
let _ = inner.proxy_auth_handle.set(handle);
}
Ok(Browser { inner })
}
}
#[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))
}
}
impl Browser {
#[must_use]
pub fn builder() -> BrowserBuilder {
BrowserBuilder::new()
}
#[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 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> {
let url = url.into();
let res = self
.inner
.conn
.call_raw("Target.createTarget", json!({ "url": url }), 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 close(self) -> Result<(), ZendriverError> {
self.inner.conn.shutdown();
let mut child_guard = self.inner.child.lock().await;
if let Some(mut child) = child_guard.take() {
#[cfg(unix)]
{
if let Some(pid) = child.id() {
#[allow(unsafe_code)]
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
}
#[cfg(not(unix))]
{
let _ = child.start_kill(); }
match timeout(SHUTDOWN_GRACE, child.wait()).await {
Ok(Ok(_status)) => {}
_ => {
let _ = child.kill().await;
}
}
}
Ok(())
}
}
impl Drop for BrowserInner {
fn drop(&mut self) {
self.conn.shutdown();
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn candidate_paths_is_nonempty() {
let v = candidate_paths();
assert!(!v.is_empty());
}
#[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 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 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);
}
#[test]
fn non_headless_launch_flags_snapshot() {
let b = BrowserBuilder::new().headless(false);
let flags = b.build_flags(std::path::Path::new("/tmp/test-user-data"));
insta::assert_yaml_snapshot!("non_headless_launch_flags", flags);
}
#[tokio::test]
async fn tab_registrar_inserts_page_target_into_tabs_map() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
assert_eq!(inner.tabs.read().await.len(), 1);
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S2",
"targetInfo": {
"targetId": "T2",
"type": "page",
"url": "about:blank",
"attached": true,
},
"waitingForDebugger": true,
}),
)
.await;
let release_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Runtime.runIfWaitingForDebugger"),
)
.await
.expect("debugger-release did not fire within 2s");
mock.reply(release_id, json!({})).await;
for _ in 0..20 {
if inner.tabs.read().await.len() == 2 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let tabs = inner.tabs.read().await;
assert_eq!(tabs.len(), 2, "expected main + new tab in registry");
assert!(tabs.contains_key("S1"), "main tab still registered");
assert!(tabs.contains_key("S2"), "new tab registered by observer");
drop(tabs);
conn.shutdown();
}
#[tokio::test]
async fn new_tab_creates_target_then_returns_registered_tab() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
let browser = Browser {
inner: inner.clone(),
};
let fut = tokio::spawn({
let b = browser.clone();
async move { b.new_tab().await }
});
let create_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Target.createTarget"),
)
.await
.expect("Target.createTarget not sent within 2s");
assert_eq!(mock.last_sent()["params"]["url"], "about:blank");
mock.reply(create_id, json!({ "targetId": "T2" })).await;
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S2",
"targetInfo": {
"targetId": "T2",
"type": "page",
"url": "about:blank",
"attached": true,
},
"waitingForDebugger": true,
}),
)
.await;
let release_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Runtime.runIfWaitingForDebugger"),
)
.await
.expect("debugger-release did not fire within 2s");
mock.reply(release_id, json!({})).await;
let new_tab = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
.await
.expect("new_tab future did not resolve within 2s")
.expect("spawned task panicked")
.expect("new_tab returned Err");
assert_eq!(new_tab.target_id(), "T2");
assert_eq!(browser.tab_count().await, 2);
let all = browser.tabs().await;
assert_eq!(all.len(), 2);
let target_ids: std::collections::HashSet<_> =
all.iter().map(|t| t.target_id().to_string()).collect();
assert!(target_ids.contains("T1"));
assert!(target_ids.contains("T2"));
conn.shutdown();
}
#[tokio::test]
async fn tab_registrar_removes_session_on_detached_event() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let extra_session = SessionHandle::new(conn.clone(), "S2");
let extra_input = InputController::new(input_profile.clone());
let extra_tab = Tab::new(extra_session, weak.clone(), extra_input, "T2".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
map.insert("S2".to_string(), extra_tab);
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
assert_eq!(inner.tabs.read().await.len(), 2);
mock.emit_event(
"Target.detachedFromTarget",
json!({ "sessionId": "S2", "targetId": "T2" }),
)
.await;
for _ in 0..50 {
if inner.tabs.read().await.len() == 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let tabs = inner.tabs.read().await;
assert_eq!(tabs.len(), 1, "expected S2 to be removed from registry");
assert!(tabs.contains_key("S1"), "main tab still registered");
assert!(!tabs.contains_key("S2"), "detached tab removed");
drop(tabs);
conn.shutdown();
}
#[tokio::test]
async fn browser_cookies_returns_jar_bound_to_browser_connection() {
use crate::cookies::Cookie;
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let (mut mock, conn) = MockConnection::pair();
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
let browser = Browser { inner };
let jar = browser.cookies();
let fut = tokio::spawn(async move {
jar.set(Cookie {
name: "sid".into(),
value: "abc".into(),
domain: ".example.com".into(),
path: "/".into(),
expires: None,
http_only: false,
secure: false,
same_site: None,
url: None,
})
.await
});
let id = mock.expect_cmd("Storage.setCookies").await;
let params = &mock.last_sent()["params"];
let arr = params["cookies"]
.as_array()
.expect("setCookies payload must carry a cookies array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["name"], "sid");
assert!(mock.last_sent().get("sessionId").is_none());
mock.reply(id, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn tabs_and_tab_count_agree_on_initial_state() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (_mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
let browser = Browser { inner };
assert_eq!(browser.tab_count().await, 1);
let tabs = browser.tabs().await;
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0].target_id(), "T1");
conn.shutdown();
}
#[tokio::test]
async fn tab_registrar_attaches_oopif_frame_under_parent_tab() {
use crate::frame::Frame;
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
{
let main_tab = inner.tabs.read().await.get("S1").cloned().unwrap();
let parent_session = main_tab.session().clone();
let placeholder = Frame::new(
"F_OOPIF".to_string(),
Some("FROOT".to_string()),
String::new(),
None,
parent_session,
Arc::downgrade(&main_tab.inner),
);
main_tab
.inner
.frames
.write()
.await
.insert("F_OOPIF".to_string(), placeholder);
}
assert_eq!(inner.tabs.read().await.len(), 1);
let parent_tab = inner.tabs.read().await.get("S1").cloned().unwrap();
{
let frames = parent_tab.inner.frames.read().await;
let placeholder = frames.get("F_OOPIF").expect("placeholder seeded");
assert_eq!(placeholder.session().session_id(), "S1");
}
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S2",
"targetInfo": {
"targetId": "F_OOPIF",
"type": "iframe",
"url": "https://oopif.example.com/",
"attached": true,
},
"waitingForDebugger": true,
}),
)
.await;
let release_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Runtime.runIfWaitingForDebugger"),
)
.await
.expect("debugger-release did not fire within 2s");
mock.reply(release_id, json!({})).await;
for _ in 0..50 {
let frames = parent_tab.inner.frames.read().await;
if frames
.get("F_OOPIF")
.is_some_and(|f| f.session().session_id() == "S2")
{
break;
}
drop(frames);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let frames = parent_tab.inner.frames.read().await;
let oopif = frames
.get("F_OOPIF")
.expect("OOPIF frame registered on parent");
assert_eq!(
oopif.session().session_id(),
"S2",
"OOPIF frame must carry the child session, not the parent's",
);
assert_eq!(oopif.id(), "F_OOPIF");
drop(frames);
assert_eq!(
inner.tabs.read().await.len(),
1,
"OOPIF must not be registered as a tab",
);
conn.shutdown();
}
#[tokio::test]
async fn tab_registrar_skips_oopif_when_parent_unknown() {
use zendriver_transport::testing::MockConnection;
let input_profile = zendriver_stealth::InputProfile::native();
let registrar = Arc::new(TabRegistrar::new(input_profile.clone()));
let (mut mock, conn) =
MockConnection::pair_with_observers(vec![registrar.clone() as Arc<dyn TargetObserver>]);
let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
let main_session = SessionHandle::new(conn.clone(), "S1");
let main_input = InputController::new(input_profile.clone());
let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
let mut map = HashMap::new();
map.insert("S1".to_string(), main_tab.clone());
BrowserInner {
conn: conn.clone(),
main_tab,
child: tokio::sync::Mutex::new(None),
_user_data: None,
stealth_input_profile: input_profile.clone(),
tabs: tokio::sync::RwLock::new(map),
tabs_changed: tokio::sync::Notify::new(),
#[cfg(feature = "interception")]
proxy_auth_handle: std::sync::OnceLock::new(),
}
});
registrar.set_browser(Arc::downgrade(&inner));
mock.emit_event(
"Target.attachedToTarget",
json!({
"sessionId": "S_ORPHAN",
"targetInfo": {
"targetId": "F_NOWHERE",
"type": "iframe",
"url": "https://orphan.example.com/",
"attached": true,
},
"waitingForDebugger": true,
}),
)
.await;
let release_id = tokio::time::timeout(
std::time::Duration::from_secs(2),
mock.expect_cmd("Runtime.runIfWaitingForDebugger"),
)
.await
.expect("debugger-release did not fire within 2s");
mock.reply(release_id, json!({})).await;
assert_eq!(inner.tabs.read().await.len(), 1);
let parent_tab = inner.tabs.read().await.get("S1").cloned().unwrap();
assert!(parent_tab.inner.frames.read().await.is_empty());
conn.shutdown();
}
}