use dashmap::DashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub use chromiumoxide::browser::Browser;
pub use chromiumoxide::error::CdpError;
pub use chromiumoxide::page::Page;
const CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub(crate) struct PageCloseGuard {
page: Arc<Page>,
defused: AtomicBool,
}
impl PageCloseGuard {
#[inline]
pub(crate) fn new(page: Arc<Page>) -> Self {
Self {
page,
defused: AtomicBool::new(false),
}
}
#[inline]
pub(crate) fn from_page(page: Page) -> Self {
Self::new(Arc::new(page))
}
#[inline]
pub(crate) fn page(&self) -> &Page {
&self.page
}
#[inline]
pub(crate) fn defuse(&self) {
self.defused.store(true, Ordering::Release);
}
}
impl Drop for PageCloseGuard {
fn drop(&mut self) {
if self.defused.load(Ordering::Acquire) {
return;
}
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let page = Page::clone(&self.page);
handle.spawn(async move {
let _ = tokio::time::timeout(CLOSE_TIMEOUT, page.close()).await;
});
}
}
}
#[inline]
fn page_key(page: &Arc<Page>) -> usize {
Arc::as_ptr(page) as usize
}
#[derive(Clone)]
pub struct BrowserContext {
browser: Arc<Browser>,
page: Arc<Page>,
page_guard: Option<Arc<PageCloseGuard>>,
owned: Arc<DashMap<usize, PageCloseGuard>>,
}
impl BrowserContext {
pub fn new(browser: Arc<Browser>, page: Arc<Page>) -> Self {
Self {
browser,
page,
page_guard: None,
owned: Default::default(),
}
}
pub fn page(&self) -> &Arc<Page> {
&self.page
}
pub fn browser(&self) -> &Arc<Browser> {
&self.browser
}
#[deprecated(
since = "2.53.1",
note = "leaks the CDP tab until the context drops; use new_page_owned()"
)]
pub async fn new_page(&self) -> Result<Arc<Page>, CdpError> {
self.new_page_tracked("about:blank").await
}
#[deprecated(
since = "2.53.1",
note = "leaks the CDP tab until the context drops; use new_page_owned()"
)]
pub async fn new_page_with_url(&self, url: &str) -> Result<Arc<Page>, CdpError> {
self.new_page_tracked(url).await
}
async fn new_page_tracked(&self, url: &str) -> Result<Arc<Page>, CdpError> {
let page = Arc::new(self.browser.new_page(url).await?);
self.owned
.insert(page_key(&page), PageCloseGuard::new(page.clone()));
Ok(page)
}
pub async fn new_page_owned(&self) -> Result<BrowserContext, CdpError> {
self.owning_context("about:blank").await
}
pub async fn new_page_with_url_owned(&self, url: &str) -> Result<BrowserContext, CdpError> {
self.owning_context(url).await
}
async fn owning_context(&self, url: &str) -> Result<BrowserContext, CdpError> {
let page = Arc::new(self.browser.new_page(url).await?);
Ok(BrowserContext {
browser: self.browser.clone(),
page: page.clone(),
page_guard: Some(Arc::new(PageCloseGuard::new(page))),
owned: Default::default(),
})
}
pub async fn close_page(&self, page: &Arc<Page>) -> Result<(), CdpError> {
if let Some((_, guard)) = self.owned.remove(&page_key(page)) {
guard.defuse();
}
Page::clone(page.as_ref()).close().await
}
pub async fn clone_page(&self) -> Result<BrowserContext, CdpError> {
let url = self
.page
.url()
.await?
.unwrap_or_else(|| "about:blank".to_string());
self.owning_context(&url).await
}
pub fn defuse_page(self) -> Arc<Page> {
if let Some(guard) = self.page_guard.as_ref() {
guard.defuse();
}
for entry in self.owned.iter() {
entry.value().defuse();
}
self.page.clone()
}
pub async fn navigate(&self, url: &str) -> Result<(), CdpError> {
self.page.goto(url).await?;
Ok(())
}
pub async fn url(&self) -> Result<Option<String>, CdpError> {
self.page.url().await
}
pub async fn html(&self) -> Result<String, CdpError> {
self.page.content().await
}
pub async fn screenshot(&self) -> Result<Vec<u8>, CdpError> {
self.page
.screenshot(
chromiumoxide::page::ScreenshotParams::builder()
.full_page(true)
.build(),
)
.await
}
pub async fn screenshot_viewport(&self) -> Result<Vec<u8>, CdpError> {
self.page
.screenshot(
chromiumoxide::page::ScreenshotParams::builder()
.full_page(false)
.build(),
)
.await
}
pub async fn click(&self, selector: &str) -> Result<(), CdpError> {
let element = self.page.find_element(selector).await?;
element.click_smooth().await?;
Ok(())
}
pub async fn click_all(&self, selector: &str) -> Result<usize, CdpError> {
let elements = self.page.find_elements(selector).await?;
let count = elements.len();
for element in elements {
let _ = element.click_smooth().await;
}
Ok(count)
}
pub async fn click_point(&self, x: f64, y: f64) -> Result<(), CdpError> {
use chromiumoxide::layout::Point;
self.page.click_smooth(Point::new(x, y)).await?;
Ok(())
}
pub async fn click_hold(&self, selector: &str, hold_ms: u64) -> Result<(), CdpError> {
let element = self.page.find_element(selector).await?;
let point = element.clickable_point().await?;
self.page.move_mouse_smooth(point).await?;
self.page
.click_and_hold(point, std::time::Duration::from_millis(hold_ms))
.await?;
Ok(())
}
pub async fn click_hold_point(&self, x: f64, y: f64, hold_ms: u64) -> Result<(), CdpError> {
use chromiumoxide::layout::Point;
let point = Point::new(x, y);
self.page.move_mouse_smooth(point).await?;
self.page
.click_and_hold(point, std::time::Duration::from_millis(hold_ms))
.await?;
Ok(())
}
pub async fn click_drag(
&self,
from_selector: &str,
to_selector: &str,
modifier: Option<i64>,
) -> Result<(), CdpError> {
let from_elem = self.page.find_element(from_selector).await?;
let to_elem = self.page.find_element(to_selector).await?;
let from_point = from_elem.clickable_point().await?;
let to_point = to_elem.clickable_point().await?;
self.click_drag_point(
(from_point.x, from_point.y),
(to_point.x, to_point.y),
modifier,
)
.await
}
pub async fn click_drag_point(
&self,
from: (f64, f64),
to: (f64, f64),
modifier: Option<i64>,
) -> Result<(), CdpError> {
use chromiumoxide::layout::Point;
let from_point = Point::new(from.0, from.1);
let to_point = Point::new(to.0, to.1);
match modifier {
Some(m) => {
self.page
.click_and_drag_smooth_with_modifier(from_point, to_point, m)
.await?
}
None => {
self.page
.click_and_drag_smooth(from_point, to_point)
.await?
}
};
Ok(())
}
pub async fn click_all_clickable(&self) -> Result<usize, CdpError> {
let script = r#"
Array.from(document.querySelectorAll('a, button, [onclick], [role="button"], input[type="submit"], input[type="button"]'))
.filter(el => {
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && el.offsetParent !== null;
})
.length
"#;
let count: usize = self
.page
.evaluate(script)
.await?
.into_value()
.map_err(|e| {
CdpError::ChromeMessage(format!("Failed to count clickable elements: {}", e))
})?;
let click_script = r#"
const elements = Array.from(document.querySelectorAll('a, button, [onclick], [role="button"], input[type="submit"], input[type="button"]'))
.filter(el => {
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && el.offsetParent !== null;
});
elements.forEach(el => { try { el.click(); } catch(e) {} });
elements.length
"#;
let clicked: usize = self
.page
.evaluate(click_script)
.await?
.into_value()
.unwrap_or(0);
Ok(clicked.min(count))
}
pub async fn type_text(&self, selector: &str, text: &str) -> Result<(), CdpError> {
let element = self.page.find_element(selector).await?;
element.click_smooth().await?;
element.type_str(text).await?;
Ok(())
}
pub async fn wait_for(&self, selector: &str) -> Result<(), CdpError> {
self.page.find_element(selector).await?;
Ok(())
}
pub async fn wait_for_timeout(&self, selector: &str, timeout_ms: u64) -> Result<(), CdpError> {
let timeout = std::time::Duration::from_millis(timeout_ms);
tokio::time::timeout(timeout, self.page.find_element(selector))
.await
.map_err(|_| CdpError::Timeout)?
.map(|_| ())
}
pub async fn wait_for_navigation(&self) -> Result<(), CdpError> {
self.page.evaluate("new Promise(r => { if (document.readyState === 'complete') r(); else window.addEventListener('load', r); })").await?;
Ok(())
}
pub async fn wait_for_dom(
&self,
selector: Option<&str>,
timeout_ms: u32,
) -> Result<(), CdpError> {
let sel = selector.unwrap_or("body");
let script = format!(
r#"
new Promise((resolve, reject) => {{
const timeout = {};
const target = document.querySelector('{}');
if (!target) {{ resolve(); return; }}
let timer;
const observer = new MutationObserver(() => {{
clearTimeout(timer);
timer = setTimeout(() => {{
observer.disconnect();
resolve();
}}, 100);
}});
observer.observe(target, {{ childList: true, subtree: true, attributes: true }});
timer = setTimeout(() => {{
observer.disconnect();
resolve();
}}, 100);
setTimeout(() => {{
observer.disconnect();
resolve();
}}, timeout);
}})
"#,
timeout_ms, sel
);
self.page.evaluate(script).await?;
Ok(())
}
pub async fn wait_and_click(&self, selector: &str) -> Result<(), CdpError> {
let element = self.page.find_element(selector).await?;
element.click_smooth().await?;
Ok(())
}
pub async fn evaluate<T: serde::de::DeserializeOwned>(
&self,
script: &str,
) -> Result<T, CdpError> {
self.page
.evaluate(script)
.await?
.into_value()
.map_err(|e| CdpError::ChromeMessage(format!("JSON conversion error: {}", e)))
}
pub async fn execute(&self, script: &str) -> Result<(), CdpError> {
self.page.evaluate(script).await?;
Ok(())
}
pub async fn scroll_x(&self, pixels: i32) -> Result<(), CdpError> {
let script = format!("window.scrollBy({}, 0)", pixels);
self.page.evaluate(script).await?;
Ok(())
}
pub async fn scroll_y(&self, pixels: i32) -> Result<(), CdpError> {
let script = format!("window.scrollBy(0, {})", pixels);
self.page.evaluate(script).await?;
Ok(())
}
pub async fn infinite_scroll(&self, max_scrolls: u32) -> Result<usize, CdpError> {
let script = r#"
(async function() {
const maxScrolls = arguments[0];
let lastHeight = document.body.scrollHeight;
let scrollCount = 0;
while (scrollCount < maxScrolls) {
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 1000));
const newHeight = document.body.scrollHeight;
if (newHeight === lastHeight) break;
lastHeight = newHeight;
scrollCount++;
}
return scrollCount;
})
"#;
let count: usize = self
.page
.evaluate(format!("({script})({max_scrolls})"))
.await?
.into_value()
.unwrap_or(0);
Ok(count)
}
pub async fn fill(&self, selector: &str, value: &str) -> Result<(), CdpError> {
let element = self.page.find_element(selector).await?;
element.click().await?;
element.click().await?;
element.click().await?;
use chromiumoxide::cdp::browser_protocol::input::{
DispatchKeyEventParams, DispatchKeyEventType,
};
self.page
.execute(
DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::KeyDown)
.key("a")
.modifiers(2) .build()
.map_err(|e| CdpError::ChromeMessage(format!("key event build: {e}")))?,
)
.await?;
self.page
.execute(
DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::KeyUp)
.key("a")
.build()
.map_err(|e| CdpError::ChromeMessage(format!("key event build: {e}")))?,
)
.await?;
element.type_str(value).await?;
Ok(())
}
pub async fn find_elements(
&self,
selector: &str,
) -> Result<Vec<chromiumoxide::element::Element>, CdpError> {
self.page.find_elements(selector).await
}
pub async fn get_element_bounds(
&self,
selector: &str,
) -> Result<Option<(f64, f64, f64, f64)>, CdpError> {
let escaped_selector = serde_json::to_string(selector).unwrap_or_else(|_| {
format!(
"\"{}\"",
selector.replace('\\', "\\\\").replace('"', "\\\"")
)
});
let script = format!(
r#"
(function() {{
const el = document.querySelector({});
if (!el) return null;
const rect = el.getBoundingClientRect();
return [rect.x, rect.y, rect.width, rect.height];
}})()
"#,
escaped_selector
);
let result: Option<Vec<f64>> = self
.page
.evaluate(script)
.await?
.into_value()
.map_err(|e| CdpError::ChromeMessage(format!("Failed to get bounds: {}", e)))?;
Ok(result.and_then(|v| {
if v.len() >= 4 {
Some((v[0], v[1], v[2], v[3]))
} else {
None
}
}))
}
pub async fn close(&self) -> Result<(), CdpError> {
self.page.evaluate("window.close()").await?;
Ok(())
}
pub fn set_page(&mut self, page: Arc<Page>) {
self.page = page;
}
pub fn with_page(&self, page: Arc<Page>) -> Self {
Self {
browser: self.browser.clone(),
page,
page_guard: None,
owned: self.owned.clone(),
}
}
}
impl std::fmt::Debug for BrowserContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BrowserContext")
.field("browser", &"Browser { ... }")
.field("page", &"Page { ... }")
.field("owns_page", &self.page_guard.is_some())
.field("owned_pages", &self.owned.len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn clone_page_context_closes_its_tab_on_drop() {
let Ok(chrome_url) = std::env::var("CHROME_URL") else {
return;
};
let (browser, mut handler) = match Browser::connect(chrome_url).await {
Ok(pair) => pair,
Err(e) => panic!("failed to connect to CHROME_URL: {e}"),
};
let drive = tokio::spawn(async move {
use futures::StreamExt;
while handler.next().await.is_some() {}
});
let browser = Arc::new(browser);
let base = Arc::new(
browser
.new_page("about:blank")
.await
.expect("base page created"),
);
let root = BrowserContext::new(browser.clone(), base.clone());
assert!(root.page_guard.is_none(), "new() must not own the page");
let before = browser.pages().await.map(|p| p.len()).unwrap_or_default();
let cloned = root.clone_page().await.expect("clone_page");
assert!(
cloned.page_guard.is_some(),
"clone_page() must own the new page"
);
let during = browser.pages().await.map(|p| p.len()).unwrap_or_default();
assert_eq!(during, before + 1, "clone_page should open one tab");
let survivor = cloned.clone();
drop(cloned);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
assert_eq!(
browser.pages().await.map(|p| p.len()).unwrap_or_default(),
during,
"an intermediate clone drop must not close the tab"
);
drop(survivor);
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
assert_eq!(
browser.pages().await.map(|p| p.len()).unwrap_or_default(),
before,
"dropping the last clone must close the owned tab"
);
assert!(base.url().await.is_ok(), "caller-owned page must stay open");
drop(root);
let _ = Page::clone(base.as_ref()).close().await;
drive.abort();
}
#[test]
fn guard_drop_without_runtime_is_a_noop() {
assert!(
tokio::runtime::Handle::try_current().is_err(),
"no runtime should be active in a plain #[test]"
);
}
}