#![cfg(feature = "integration-tests")]
#![allow(clippy::panic, clippy::unwrap_used)]
use std::time::Duration;
use serial_test::serial;
use zendriver::Browser;
use zendriver::stealth::StealthProfile;
#[tokio::test]
#[serial]
async fn ua_override_persists_across_new_tabs() {
const UA: &str = "ZendriverPortUA/0.1";
let browser = Browser::builder()
.headless(true)
.stealth(StealthProfile::native().user_agent(UA))
.launch()
.await
.expect("launch");
let main = browser.main_tab();
let main_ua: String = main
.evaluate("navigator.userAgent")
.await
.expect("read UA on main tab");
assert_eq!(
main_ua, UA,
"main tab must report the overridden UA, got {main_ua:?}"
);
for i in 0..3 {
let tab = browser
.new_tab()
.await
.unwrap_or_else(|e| panic!("open new tab #{i}: {e}"));
let ua: String = tab
.evaluate("navigator.userAgent")
.await
.unwrap_or_else(|e| panic!("read UA on new tab #{i}: {e}"));
assert_eq!(
ua, UA,
"new tab #{i} must inherit overridden UA, got {ua:?}"
);
}
browser.close().await.expect("close");
}
fn chrome_major_from_ua(ua: &str) -> u32 {
let rest = ua
.find("Chrome/")
.map(|i| &ua[i + "Chrome/".len()..])
.unwrap_or_else(|| panic!("no `Chrome/` token in UA: {ua:?}"));
rest.split('.')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| panic!("no numeric major in UA: {ua:?}"))
}
#[tokio::test]
#[serial]
async fn stealth_ua_major_matches_the_real_chrome_binary() {
let real_major = {
let browser = Browser::builder()
.headless(true)
.stealth(StealthProfile::off())
.launch()
.await
.expect("launch with stealth off");
let ua: String = browser
.main_tab()
.evaluate("navigator.userAgent")
.await
.expect("read real UA");
browser.close().await.expect("close");
chrome_major_from_ua(&ua)
};
let claimed_major = {
let browser = Browser::builder()
.headless(true)
.stealth(StealthProfile::native())
.launch()
.await
.expect("launch with stealth native");
let ua: String = browser
.main_tab()
.evaluate("navigator.userAgent")
.await
.expect("read stealth UA");
browser.close().await.expect("close");
chrome_major_from_ua(&ua)
};
assert_eq!(
claimed_major, real_major,
"stealth UA claims Chrome {claimed_major} but the real binary is Chrome \
{real_major}; the version probe is falling back instead of detecting \
the installed Chrome",
);
}
fn is_chrome_binary_name(s: &str) -> bool {
let lower = s.to_lowercase();
lower.contains("chrome") || lower.contains("chromium")
}
fn is_chrome_process(proc: &sysinfo::Process) -> bool {
if is_chrome_binary_name(&proc.name().to_string_lossy()) {
return true;
}
proc.exe()
.and_then(|p| p.file_name())
.is_some_and(|n| is_chrome_binary_name(&n.to_string_lossy()))
}
fn chrome_pids() -> std::collections::HashSet<u32> {
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
let mut sys = System::new();
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
true,
ProcessRefreshKind::new().with_exe(UpdateKind::Always),
);
sys.processes()
.iter()
.filter(|(_, proc)| is_chrome_process(proc))
.map(|(pid, _)| pid.as_u32())
.collect()
}
#[tokio::test]
#[serial]
async fn repeated_launch_close_leaves_no_orphan_chrome_processes() {
let before = chrome_pids();
for i in 0..25 {
let browser = Browser::builder()
.headless(true)
.launch()
.await
.unwrap_or_else(|e| panic!("launch iter {i}: {e}"));
let _ = browser.main_tab().goto("about:blank").await;
if i == 0 {
let live = chrome_pids();
assert!(
live.difference(&before).count() > 0,
"process audit saw no new Chrome PID while a browser was \
live — the matcher is blind on this platform, so the leak \
check below would pass without auditing anything",
);
}
browser
.close()
.await
.unwrap_or_else(|e| panic!("close iter {i}: {e}"));
}
tokio::time::sleep(Duration::from_millis(500)).await;
let after = chrome_pids();
let leaked: Vec<u32> = after.difference(&before).copied().collect();
assert!(
leaked.is_empty(),
"{} Chrome PIDs leaked after 25 launch/close cycles: {:?}",
leaked.len(),
leaked,
);
}
#[tokio::test]
#[serial]
async fn browser_close_during_inflight_cdp_calls_does_not_hang() {
use std::sync::Arc;
use tokio::time::timeout;
const N: usize = 10;
let browser = Browser::builder()
.headless(true)
.launch()
.await
.expect("launch");
let main = browser.main_tab();
main.goto("about:blank").await.expect("goto");
let tab = Arc::new(main);
let mut handles = Vec::with_capacity(N);
for i in 0..N {
let tab = Arc::clone(&tab);
handles.push(tokio::spawn(async move {
let result: Result<serde_json::Value, _> = tab
.evaluate("new Promise(r => setTimeout(() => r(42), 1000))")
.await;
(i, result)
}));
}
tokio::time::sleep(Duration::from_millis(50)).await;
browser.close().await.expect("close");
let mut hung = Vec::new();
for h in handles {
match timeout(Duration::from_secs(5), h).await {
Ok(Ok((_i, _call_result))) => {
}
Ok(Err(join_err)) => {
panic!("task panicked while awaiting in-flight call: {join_err}");
}
Err(_elapsed) => {
hung.push(());
}
}
}
assert!(
hung.is_empty(),
"{} of {} in-flight CDP calls hung past close (5s timeout)",
hung.len(),
N,
);
}