use std::time::Duration;
use chromiumoxide::Page;
use scraper::Selector;
use crate::error::{Error, Result};
use crate::locator::{locator_to_selector, parse_locator, Locator};
use crate::wait::WaitOptions;
fn json_escape(s: impl AsRef<str>) -> String {
serde_json::to_string(s.as_ref()).unwrap_or_else(|_| format!("\"{}\"", s.as_ref()))
}
#[derive(Debug, Clone)]
pub enum PageRef {
Cdp(Page),
Session,
}
#[derive(Debug, Clone)]
pub struct Element {
page: Option<Page>,
page_ref: PageRef,
locator: Option<Locator>,
object_id: Option<String>,
fallback_xpath: Option<String>,
html: String,
tag: String,
text: String,
attrs: Vec<(String, String)>,
}
impl Element {
pub fn new_cdp(
page: Page,
object_id: String,
locator: Option<Locator>,
html: String,
tag: String,
text: String,
attrs: Vec<(String, String)>,
fallback_xpath: Option<String>,
) -> Self {
Self {
page: Some(page.clone()),
page_ref: PageRef::Cdp(page),
locator,
object_id: Some(object_id),
fallback_xpath,
html,
tag,
text,
attrs,
}
}
pub fn new_cdp_with_xpath(
page: Page,
locator: Option<Locator>,
xpath: String,
html: String,
tag: String,
text: String,
attrs: Vec<(String, String)>,
) -> Self {
Self {
page: Some(page.clone()),
page_ref: PageRef::Cdp(page),
locator,
object_id: None,
fallback_xpath: Some(xpath),
html,
tag,
text,
attrs,
}
}
pub fn new_session(
locator: Option<Locator>,
html: String,
tag: String,
text: String,
attrs: Vec<(String, String)>,
) -> Self {
Self {
page: None,
page_ref: PageRef::Session,
locator,
object_id: None,
fallback_xpath: None,
html,
tag,
text,
attrs,
}
}
pub fn tag(&self) -> &str {
&self.tag
}
pub fn text(&self) -> &str {
&self.text
}
pub fn html(&self) -> &str {
&self.html
}
pub fn attr(&self, name: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
pub fn attrs(&self) -> &[(String, String)] {
&self.attrs
}
pub fn page_ref(&self) -> &PageRef {
&self.page_ref
}
pub fn is_cdp(&self) -> bool {
self.page.is_some()
}
pub fn is_displayed(&self) -> bool {
let html = self.html.to_lowercase();
if html.contains("display:none")
|| html.contains("display: none")
|| html.contains("hidden")
{
return false;
}
if self.object_id.is_some()
&& (html.contains("visibility:hidden") || html.contains("visibility: hidden"))
{
return false;
}
!html.is_empty()
}
pub fn is_enabled(&self) -> bool {
!self.attrs.iter().any(|(k, _)| k == "disabled")
}
pub async fn is_visible(&self) -> bool {
let page = match self.page.as_ref() {
Some(p) => p,
None => return self.is_displayed(),
};
if let Some(ref oid) = self.object_id {
if !oid.is_empty() {
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let fn_decl = "function(){\
if(!this||!this.isConnected) return false;\
var s=getComputedStyle(this);\
if(s.display==='none') return false;\
if(s.visibility==='collapse'||s.visibility==='hidden') return false;\
if(parseFloat(s.opacity)<=0) return false;\
if(this.hasAttribute('hidden')) return false;\
if(this.offsetWidth||this.offsetHeight||this.getClientRects().length) return true;\
return s.display!=='none';\
}";
let params = match CallFunctionOnParams::builder()
.object_id(oid.clone())
.function_declaration(fn_decl)
.return_by_value(true)
.build()
{
Ok(p) => p,
Err(_) => return self.is_displayed(),
};
match page.execute(params).await {
Ok(result) => result
.result
.result
.value
.and_then(|v| v.as_bool())
.unwrap_or(false),
Err(_) => self.is_displayed(),
}
} else {
self.is_displayed()
}
} else {
self.is_displayed()
}
}
pub fn locator(&self) -> Option<&Locator> {
self.locator.as_ref()
}
async fn cdp_element(&self) -> Result<chromiumoxide::Element> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("requires Chromium mode".into()))?;
let locator = self
.locator
.as_ref()
.ok_or(Error::Browser("no locator for element".into()))?;
let selector = locator_to_selector(locator)?;
page.find_element(&selector)
.await
.map_err(|e| Error::Browser(format!("re-resolve element: {e}")))
}
pub async fn click(&self) -> Result<()> {
let page = self.page.as_ref()
.ok_or_else(|| Error::Browser("requires Chromium mode".into()))?;
if let Some(ref xpath) = self.fallback_xpath {
let escaped = serde_json::to_string(xpath).unwrap_or_else(|_| format!("\"{}\"", xpath));
let js = format!(
"(function() {{ \
var result = document.evaluate({xp}, document, null, \
XPathResult.FIRST_ORDERED_NODE_TYPE, null); \
var el = result.singleNodeValue; \
if (!el) return null; \
el.scrollIntoView({{block:'center'}}); \
var r = el.getBoundingClientRect(); \
return JSON.stringify({{x: r.x + r.width/2, y: r.y + r.height/2}}); \
}})()",
xp = escaped
);
let val = page.evaluate(js.as_str()).await
.map_err(|e| Error::Browser(format!("xpath click coord: {e}")))?;
let coord_str = val.value().and_then(|v| v.as_str())
.ok_or_else(|| Error::ElementNotFound(format!("xpath click: element not found: {}", xpath)))?;
let coord: serde_json::Value = serde_json::from_str(coord_str)
.map_err(|e| Error::Browser(format!("parse coord: {e}")))?;
let x = coord["x"].as_f64().unwrap_or(0.0);
let y = coord["y"].as_f64().unwrap_or(0.0);
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
};
let moved = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(x).y(y)
.build().map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(moved).await.ok();
let pressed = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(x).y(y)
.button(MouseButton::Left)
.click_count(1)
.build().map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(pressed).await
.map_err(|e| Error::Browser(format!("mouse press: {e}")))?;
let released = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(x).y(y)
.button(MouseButton::Left)
.click_count(1)
.build().map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(released).await
.map_err(|e| Error::Browser(format!("mouse release: {e}")))?;
return Ok(());
}
let cdp_el = self.cdp_element().await?;
if cdp_el.scroll_into_view().await.is_ok() && cdp_el.click().await.is_ok() {
return Ok(());
}
self.js("this.click()").await?;
Ok(())
}
pub async fn input(&self, text: &str) -> Result<()> {
let cdp_el = self.cdp_element().await?;
cdp_el
.scroll_into_view()
.await
.map_err(|e| Error::Browser(format!("scroll: {e}")))?;
cdp_el
.click()
.await
.map_err(|e| Error::Browser(format!("focus: {e}")))?;
cdp_el
.type_str(text)
.await
.map_err(|e| Error::Browser(format!("type: {e}")))?;
Ok(())
}
pub async fn fill(&self, text: &str) -> Result<()> {
let escaped = json_escape(text);
let js = format!(
"(function() {{ \
this.focus(); \
var proto = Object.getPrototypeOf(this); \
var desc = null; \
while (proto) {{ \
desc = Object.getOwnPropertyDescriptor(proto, 'value'); \
if (desc) break; \
proto = Object.getPrototypeOf(proto); \
}} \
if (desc && desc.set) {{ \
desc.set.call(this, {}); \
this.dispatchEvent(new Event('input', {{bubbles: true}})); \
this.dispatchEvent(new Event('change', {{bubbles: true}})); \
}} \
}}).call(this);",
escaped
);
self.js(&js).await?;
Ok(())
}
pub async fn clear(&self) -> Result<()> {
let cdp_el = self.cdp_element().await?;
cdp_el
.click()
.await
.map_err(|e| Error::Browser(format!("focus: {e}")))?;
let page = self
.page
.as_ref()
.ok_or(Error::Browser("requires Chromium mode".into()))?;
use chromiumoxide::cdp::browser_protocol::input::{
DispatchKeyEventParams, DispatchKeyEventType,
};
let select_all = DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::KeyDown)
.key("a")
.code("KeyA")
.windows_virtual_key_code(0x41)
.modifiers(2)
.build()
.unwrap();
page.execute(select_all)
.await
.map_err(|e| Error::Browser(format!("select all: {e}")))?;
let del = DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::KeyDown)
.key("Delete")
.code("Delete")
.windows_virtual_key_code(0x2E)
.build()
.unwrap();
page.execute(del)
.await
.map_err(|e| Error::Browser(format!("delete: {e}")))?;
Ok(())
}
pub async fn hover(&self) -> Result<()> {
let cdp_el = self.cdp_element().await?;
cdp_el
.scroll_into_view()
.await
.map_err(|e| Error::Browser(format!("scroll: {e}")))?;
cdp_el
.hover()
.await
.map_err(|e| Error::Browser(format!("hover: {e}")))?;
Ok(())
}
pub async fn scroll_into_view(&self) -> Result<()> {
let cdp_el = self.cdp_element().await?;
cdp_el
.scroll_into_view()
.await
.map_err(|e| Error::Browser(format!("scroll: {e}")))?;
Ok(())
}
pub async fn press_key(&self, key: &str) -> Result<()> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("requires Chromium mode".into()))?;
let cdp_el = self.cdp_element().await?;
cdp_el
.click()
.await
.map_err(|e| Error::Browser(format!("focus: {e}")))?;
cdp_el
.press_key(key)
.await
.map_err(|e| Error::Browser(format!("press_key: {e}")))?;
let _ = page;
Ok(())
}
pub async fn value(&self) -> Result<String> {
let cdp_el = self.cdp_element().await?;
cdp_el
.string_property("value")
.await
.map(|v| v.unwrap_or_default())
.map_err(|e| Error::Browser(format!("value: {e}")))
}
pub async fn rect(&self) -> Result<(f64, f64, f64, f64)> {
let cdp_el = self.cdp_element().await?;
let bb = cdp_el
.bounding_box()
.await
.map_err(|e| Error::Browser(format!("bounding_box: {e}")))?;
Ok((bb.x, bb.y, bb.width, bb.height))
}
pub async fn is_selected(&self) -> Result<bool> {
let cdp_el = self.cdp_element().await?;
Ok(cdp_el
.string_property("checked")
.await
.ok()
.flatten()
.is_some()
|| cdp_el
.string_property("selected")
.await
.ok()
.flatten()
.is_some())
}
pub async fn style(&self, property: &str) -> Result<String> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("requires Chromium mode".into()))?;
if let Some(ref oid) = self.object_id {
if !oid.is_empty() {
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let function_decl = format!(
"function() {{ return getComputedStyle(this).getPropertyValue({}); }}",
json_escape(property)
);
let params = CallFunctionOnParams::builder()
.object_id(oid.clone())
.function_declaration(function_decl)
.await_promise(false)
.return_by_value(true)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
let result = page
.execute(params)
.await
.map_err(|e| Error::Browser(format!("style: {e}")))?;
if let Some(val) = result.result.result.value {
Ok(val.as_str().unwrap_or("").to_string())
} else {
Ok(String::new())
}
} else {
Ok(String::new())
}
} else {
Ok(String::new())
}
}
pub async fn select(&self, text: &str) -> Result<()> {
let escaped = json_escape(text);
let js = format!(
"(function() {{ \
var opts = this.options; \
for (var i = 0; i < opts.length; i++) {{ \
if (opts[i].text === {}) {{ \
this.selectedIndex = i; \
opts[i].selected = true; \
this.dispatchEvent(new Event('change', {{bubbles: true}})); \
return; \
}} \
}} \
}}).call(this);",
escaped
);
self.js(&js).await
}
pub async fn select_by_value(&self, value: &str) -> Result<()> {
let escaped = json_escape(value);
let js = format!(
"(function() {{ \
var opts = this.options; \
for (var i = 0; i < opts.length; i++) {{ \
if (opts[i].value === {}) {{ \
this.selectedIndex = i; \
opts[i].selected = true; \
this.dispatchEvent(new Event('change', {{bubbles: true}})); \
return; \
}} \
}} \
}}).call(this);",
escaped
);
self.js(&js).await
}
pub async fn upload_file(&self, path: &str) -> Result<()> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("upload_file requires Chromium mode".into()))?;
let cdp_el = self.cdp_element().await?;
use chromiumoxide::cdp::browser_protocol::dom::SetFileInputFilesParams;
let params = SetFileInputFilesParams::builder()
.file(path)
.object_id(cdp_el.remote_object_id.clone())
.build()
.map_err(|e| Error::Browser(format!("build setFileInputFiles: {e}")))?;
page.execute(params)
.await
.map_err(|e| Error::Browser(format!("setFileInputFiles: {e}")))?;
Ok(())
}
pub async fn right_click(&self) -> Result<()> {
self.js("this.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true}))")
.await
}
pub async fn double_click(&self) -> Result<()> {
self.js("this.dispatchEvent(new MouseEvent('dblclick', {bubbles: true}))")
.await
}
pub async fn screenshot(&self, path: &str) -> Result<()> {
let cdp_el = self.cdp_element().await?;
let _ = cdp_el.scroll_into_view().await;
let page = self
.page
.as_ref()
.ok_or_else(|| Error::Browser("screenshot requires Chromium mode".into()))?;
let bbox = cdp_el
.bounding_box()
.await
.map_err(|e| Error::Browser(format!("bbox: {e}")))?;
use chromiumoxide::cdp::browser_protocol::page::Viewport;
use chromiumoxide::page::ScreenshotParams;
let clip = Viewport {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
scale: 1.0,
};
let params = ScreenshotParams::builder().clip(clip).build();
let bytes = page
.screenshot(params)
.await
.map_err(|e| Error::Browser(format!("screenshot: {e}")))?;
std::fs::write(path, bytes)?;
Ok(())
}
pub async fn submit(&self) -> Result<()> {
self.js("this.form ? this.form.submit() : this.submit()")
.await
}
pub async fn drag_to(&self, target: &Element) -> Result<()> {
let src = self.cdp_element().await?;
let tgt = target.cdp_element().await?;
let _ = src.scroll_into_view().await;
let src_bbox = src
.bounding_box()
.await
.map_err(|e| Error::Browser(format!("src bbox: {e}")))?;
let tgt_bbox = tgt
.bounding_box()
.await
.map_err(|e| Error::Browser(format!("tgt bbox: {e}")))?;
let src_x = src_bbox.x + src_bbox.width / 2.0;
let src_y = src_bbox.y + src_bbox.height / 2.0;
let tgt_x = tgt_bbox.x + tgt_bbox.width / 2.0;
let tgt_y = tgt_bbox.y + tgt_bbox.height / 2.0;
let page = self
.page
.as_ref()
.ok_or_else(|| Error::Browser("drag_to requires Chromium mode".into()))?;
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
};
let move_to_src = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(src_x)
.y(src_y)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(move_to_src).await.ok();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let press = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(src_x)
.y(src_y)
.button(MouseButton::Left)
.click_count(1)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(press).await.ok();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let move_to_tgt = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(tgt_x)
.y(tgt_y)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(move_to_tgt).await.ok();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let release = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(tgt_x)
.y(tgt_y)
.button(MouseButton::Left)
.click_count(1)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(release).await.ok();
Ok(())
}
pub async fn drag_to_offset(&self, offset_x: f64, offset_y: f64) -> Result<()> {
let src = self.cdp_element().await?;
let _ = src.scroll_into_view().await;
let src_bbox = src
.bounding_box()
.await
.map_err(|e| Error::Browser(format!("src bbox: {e}")))?;
let src_x = src_bbox.x + src_bbox.width / 2.0;
let src_y = src_bbox.y + src_bbox.height / 2.0;
let tgt_x = src_x + offset_x;
let tgt_y = src_y + offset_y;
let page = self
.page
.as_ref()
.ok_or_else(|| Error::Browser("drag_to_offset requires Chromium mode".into()))?;
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
};
let move_to_src = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(src_x)
.y(src_y)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(move_to_src).await.ok();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let press = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(src_x)
.y(src_y)
.button(MouseButton::Left)
.click_count(1)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(press).await.ok();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let move_to_tgt = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(tgt_x)
.y(tgt_y)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(move_to_tgt).await.ok();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let release = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(tgt_x)
.y(tgt_y)
.button(MouseButton::Left)
.click_count(1)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(release).await.ok();
Ok(())
}
pub async fn double_click_cdp(&self) -> Result<()> {
let cdp_el = self.cdp_element().await?;
cdp_el
.scroll_into_view()
.await
.map_err(|e| Error::Browser(format!("scroll: {e}")))?;
let bbox = cdp_el
.bounding_box()
.await
.map_err(|e| Error::Browser(format!("bbox: {e}")))?;
let page = self
.page
.as_ref()
.ok_or_else(|| Error::Browser("double_click_cdp requires Chromium mode".into()))?;
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
};
let x = bbox.x + bbox.width / 2.0;
let y = bbox.y + bbox.height / 2.0;
let press = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(x)
.y(y)
.button(MouseButton::Left)
.click_count(2)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(press).await.ok();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let release = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(x)
.y(y)
.button(MouseButton::Left)
.click_count(2)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(release).await.ok();
Ok(())
}
pub async fn right_click_cdp(&self) -> Result<()> {
let cdp_el = self.cdp_element().await?;
cdp_el
.scroll_into_view()
.await
.map_err(|e| Error::Browser(format!("scroll: {e}")))?;
let bbox = cdp_el
.bounding_box()
.await
.map_err(|e| Error::Browser(format!("bbox: {e}")))?;
let page = self
.page
.as_ref()
.ok_or_else(|| Error::Browser("right_click_cdp requires Chromium mode".into()))?;
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
};
let x = bbox.x + bbox.width / 2.0;
let y = bbox.y + bbox.height / 2.0;
let press = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(x)
.y(y)
.button(MouseButton::Right)
.click_count(1)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(press).await.ok();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let release = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(x)
.y(y)
.button(MouseButton::Right)
.click_count(1)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
page.execute(release).await.ok();
Ok(())
}
pub async fn check(&self) -> Result<()> {
self.js("if (!this.checked) { this.click(); }").await
}
pub async fn uncheck(&self) -> Result<()> {
self.js("if (this.checked) { this.click(); }").await
}
pub async fn select_option(&self, value: &str) -> Result<()> {
let escaped = json_escape(value);
let js = format!(
"(function() {{ \
if (this.tagName === 'OPTION') {{ \
this.selected = true; \
this.parentElement.dispatchEvent(new Event('change', {{bubbles: true}})); \
}} else if (this.tagName === 'SELECT') {{ \
for (var i = 0; i < this.options.length; i++) {{ \
var opt = this.options[i]; \
if (opt.value === {} || opt.text === {}) {{ \
opt.selected = true; \
this.dispatchEvent(new Event('change', {{bubbles: true}})); \
return; \
}} \
}} \
}} \
}}).call(this);",
escaped, escaped
);
self.js(&js).await
}
pub async fn focus(&self) -> Result<()> {
self.js("this.focus()").await
}
pub async fn blur(&self) -> Result<()> {
self.js("this.blur()").await
}
pub async fn bounding_box(&self) -> Result<(f64, f64, f64, f64)> {
let cdp_el = self.cdp_element().await?;
match cdp_el.bounding_box().await {
Ok(bbox) => Ok((bbox.x, bbox.y, bbox.width, bbox.height)),
Err(_) => {
let page = self.page.as_ref()
.ok_or_else(|| Error::Browser("bounding_box: no page ref".into()))?;
let oid = self.object_id.clone()
.ok_or_else(|| Error::Browser("bounding_box: no object_id".into()))?;
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let params = CallFunctionOnParams::builder()
.object_id(oid)
.function_declaration(
"function(){var r=this.getBoundingClientRect();return [r.x,r.y,r.width,r.height]}"
)
.return_by_value(true)
.build()
.map_err(|e| Error::Browser(format!("bounding_box: {e}")))?;
let result = page.execute(params).await
.map_err(|e| Error::Browser(format!("bounding_box js: {e}")))?;
let arr = result.result.result.value
.ok_or_else(|| Error::Browser("bounding_box: no value".into()))?;
let vals: Vec<f64> = arr.as_array()
.ok_or_else(|| Error::Browser("bounding_box: not array".into()))?
.iter().filter_map(|v| v.as_f64()).collect();
if vals.len() >= 4 {
Ok((vals[0], vals[1], vals[2], vals[3]))
} else {
Err(Error::Browser("bounding_box: insufficient values".into()))
}
}
}
}
pub async fn select_text(&self) -> Result<()> {
self.js("this.focus(); this.select()").await
}
pub async fn upload_files(&self, file_paths: &[&str]) -> Result<()> {
let page = self
.page
.as_ref()
.ok_or_else(|| Error::Browser("upload_files requires Chromium mode".into()))?;
let cdp_el = self.cdp_element().await?;
use chromiumoxide::cdp::browser_protocol::dom::SetFileInputFilesParams;
let files: Vec<String> = file_paths.iter().map(|s| s.to_string()).collect();
let params = SetFileInputFilesParams::builder()
.files(files)
.object_id(cdp_el.remote_object_id.clone())
.build()
.map_err(|e| Error::Browser(format!("build setFileInputFiles: {e}")))?;
page.execute(params)
.await
.map_err(|e| Error::Browser(format!("setFileInputFiles: {e}")))?;
Ok(())
}
pub async fn screenshot_bytes(&self) -> Result<Vec<u8>> {
let cdp_el = self.cdp_element().await?;
cdp_el
.scroll_into_view()
.await
.map_err(|e| Error::Browser(format!("scroll: {e}")))?;
let page = self
.page
.as_ref()
.ok_or_else(|| Error::Browser("screenshot_bytes requires Chromium mode".into()))?;
let bbox = cdp_el
.bounding_box()
.await
.map_err(|e| Error::Browser(format!("bbox: {e}")))?;
use chromiumoxide::cdp::browser_protocol::page::Viewport;
use chromiumoxide::page::ScreenshotParams;
let clip = Viewport {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
scale: 1.0,
};
let params = ScreenshotParams::builder().clip(clip).build();
let bytes = page
.screenshot(params)
.await
.map_err(|e| Error::Browser(format!("screenshot_bytes: {e}")))?;
Ok(bytes)
}
pub async fn scroll_to_top(&self) -> Result<()> {
self.js("this.scrollIntoView({behavior: 'instant', block: 'start'})")
.await
}
pub async fn set_attr(&self, name: &str, value: &str) -> Result<()> {
let escaped_name = json_escape(name);
let escaped_value = json_escape(value);
let js = format!("this.setAttribute({}, {})", escaped_name, escaped_value);
self.js(&js).await
}
pub async fn parent(&self) -> Result<Element> {
self.js_element("this.parentElement").await
}
pub async fn first_child(&self) -> Result<Element> {
self.js_element("this.firstElementChild").await
}
pub async fn next(&self) -> Result<Element> {
self.js_element("this.nextElementSibling").await
}
pub async fn prev(&self) -> Result<Element> {
self.js_element("this.previousElementSibling").await
}
async fn js_element(&self, js_expr: &str) -> Result<Element> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("requires Chromium mode".into()))?;
if let Some(ref oid) = self.object_id {
if !oid.is_empty() {
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let fn_decl_obj = format!(
"function() {{ var el = {}; return el; }}",
js_expr
);
let params_obj = CallFunctionOnParams::builder()
.object_id(oid.clone())
.function_declaration(fn_decl_obj)
.return_by_value(false)
.build()
.map_err(|e| Error::Browser(format!("build obj: {e}")))?;
let result_obj = page
.execute(params_obj)
.await
.map_err(|e| Error::Browser(format!("js_element obj: {e}")))?;
let new_oid = result_obj
.result
.result
.object_id
.ok_or_else(|| Error::ElementNotFound("js_element: null element".into()))?;
let fn_decl_html = "function() { return this.outerHTML; }".to_string();
let params_html = CallFunctionOnParams::builder()
.object_id(new_oid.clone())
.function_declaration(fn_decl_html)
.return_by_value(true)
.build()
.map_err(|e| Error::Browser(format!("build html: {e}")))?;
let result_html = page
.execute(params_html)
.await
.map_err(|e| Error::Browser(format!("js_element html: {e}")))?;
let html_str = result_html
.result
.result
.value
.and_then(|v| v.as_str().map(String::from))
.ok_or_else(|| Error::ElementNotFound("no outerHTML".into()))?;
let doc = scraper::Html::parse_document(&html_str);
let sel = Selector::parse("*").unwrap();
let el_ref = doc
.select(&sel)
.next()
.ok_or_else(|| Error::ElementNotFound("parse element".into()))?;
let tag = el_ref.value().name().to_string();
let attrs: Vec<(String, String)> = el_ref
.value()
.attrs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let text = el_ref.text().collect::<Vec<_>>().join("");
let el_html = el_ref.html();
Ok(Element::new_cdp(
page.clone(),
new_oid.into(),
None, el_html,
tag,
text,
attrs,
None, ))
} else {
Err(Error::ElementNotFound("no object_id".into()))
}
} else {
Err(Error::ElementNotFound("no CDP".into()))
}
}
pub async fn js(&self, script: &str) -> Result<()> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("js requires Chromium mode".into()))?;
if let Some(ref oid) = self.object_id {
if !oid.is_empty() {
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let function_decl = format!("function() {{ {script} }}");
let params = CallFunctionOnParams::builder()
.object_id(oid.clone())
.function_declaration(function_decl)
.await_promise(true)
.build()
.map_err(|e| Error::Browser(format!("build callFunctionOn: {e}")))?;
page.execute(params)
.await
.map_err(|e| Error::Browser(format!("element js: {e}")))?;
return Ok(());
}
}
let locator = self
.locator
.as_ref()
.ok_or(Error::Browser("no locator for element".into()))?;
let wrapped = if locator.is_css() {
let selector = locator_to_selector(locator)?;
format!(
"(function(){{ var el = document.querySelector({}); if(!el) return; (function(){{ {} }}).call(el); }})()",
json_escape(&selector),
script,
)
} else {
let xpath = locator
.to_xpath()
.ok_or_else(|| Error::InvalidLocator("cannot convert to XPath".into()))?;
format!(
"(function(){{ var result = document.evaluate({}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); var el = result.singleNodeValue; if(!el) return; (function(){{ {} }}).call(el); }})()",
json_escape(&xpath),
script,
)
};
page.evaluate(wrapped.as_str())
.await
.map_err(|e| Error::Browser(format!("element js: {e}")))?;
Ok(())
}
pub fn ele(&self, locator_str: &str) -> Result<Element> {
let locator = parse_locator(locator_str)?;
let doc = scraper::Html::parse_document(&self.html);
match &locator {
Locator::Css(sel) => {
let selector =
Selector::parse(sel).map_err(|e| Error::InvalidLocator(e.to_string()))?;
doc.select(&selector)
.next()
.map(|el| from_scraper_element(&el, Some(locator), self.page.clone()))
.ok_or_else(|| Error::ElementNotFound(format!("sub-element: {locator_str}")))
}
_ => {
let xpath = locator
.to_xpath()
.ok_or_else(|| Error::InvalidLocator("cannot convert to XPath".into()))?;
let package = sxd_document::parser::parse(&self.html)
.map_err(|e| Error::InvalidLocator(e.to_string()))?;
let document = package.as_document();
let value = sxd_xpath::evaluate_xpath(&document, &xpath)
.map_err(|e| Error::InvalidLocator(format!("XPath: {e}")))?;
let nodes = match value {
sxd_xpath::Value::Nodeset(ns) => ns,
_ => return Err(Error::ElementNotFound("XPath: non-nodeset".into())),
};
let node = nodes
.iter()
.next()
.ok_or_else(|| Error::ElementNotFound(format!("sub-element: {locator_str}")))?;
Ok(Element::new_session(
Some(locator),
node.string_value(),
String::new(),
String::new(),
Vec::new(),
))
}
}
}
pub fn eles(&self, locator_str: &str) -> Result<Vec<Element>> {
let locator = parse_locator(locator_str)?;
let doc = scraper::Html::parse_document(&self.html);
match &locator {
Locator::Css(sel) => {
let selector =
Selector::parse(sel).map_err(|e| Error::InvalidLocator(e.to_string()))?;
Ok(doc
.select(&selector)
.map(|el| from_scraper_element(&el, Some(locator.clone()), self.page.clone()))
.collect())
}
_ => {
let xpath = locator
.to_xpath()
.ok_or_else(|| Error::InvalidLocator("cannot convert to XPath".into()))?;
let package = sxd_document::parser::parse(&self.html)
.map_err(|e| Error::InvalidLocator(e.to_string()))?;
let document = package.as_document();
let value = sxd_xpath::evaluate_xpath(&document, &xpath)
.map_err(|e| Error::InvalidLocator(format!("XPath: {e}")))?;
let nodes = match value {
sxd_xpath::Value::Nodeset(ns) => ns,
_ => return Ok(Vec::new()),
};
Ok(nodes
.iter()
.map(|node| {
Element::new_session(
Some(locator.clone()),
node.string_value(),
String::new(),
String::new(),
Vec::new(),
)
})
.collect())
}
}
}
pub async fn shadow_ele(&self, selector: &str) -> Result<Element> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("shadow_ele requires Chromium mode".into()))?;
let oid = self
.object_id
.as_deref()
.ok_or(Error::Browser("no object_id for element".into()))?;
let parts: Vec<&str> = selector.split(">>>").map(|s| s.trim()).collect();
if parts.is_empty() {
return Err(Error::InvalidLocator("empty selector".into()));
}
let first_sel = json_escape(parts[0]);
let js = if parts.len() == 1 {
format!(
"function() {{ \
if (!this.shadowRoot) return null; \
return this.shadowRoot.querySelector({sel}); \
}}",
sel = first_sel
)
} else {
let mut body = String::from(
"if (!this.shadowRoot) return null; \
var cur = this.shadowRoot;",
);
let inner_sels: Vec<String> = parts
.iter()
.map(json_escape)
.collect();
for (i, sel) in inner_sels.iter().enumerate() {
if i < inner_sels.len() - 1 {
body.push_str(&format!(
" cur = cur.querySelector({sel}); \
if (!cur || !cur.shadowRoot) return null; \
cur = cur.shadowRoot;",
sel = sel
));
} else {
body.push_str(&format!(" return cur.querySelector({sel});", sel = sel));
}
}
format!("function() {{ {body} }}")
};
let full_fn = {
let inner_fn = js
.trim_start_matches("function() {")
.trim_end_matches('}')
.trim();
format!(
"function() {{ \
(function() {{ {inner_fn} }}).call(this); \
var el = (function() {{ {inner_fn} }}).call(this); \
if (!el) return null; \
var r = {{}}; \
r.html = el.outerHTML; \
r.tag = el.tagName.toLowerCase(); \
r.text = el.innerText || ''; \
var attrs = []; \
for (var i = 0; i < el.attributes.length; i++) {{ \
var a = el.attributes[i]; \
attrs.push([a.name, a.value]); \
}} \
r.attrs = attrs; \
return JSON.stringify(r); \
}}"
)
};
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let params = CallFunctionOnParams::builder()
.object_id(oid.to_string())
.function_declaration(full_fn)
.return_by_value(true)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
let result = page
.execute(params)
.await
.map_err(|e| Error::Browser(format!("shadow_ele: {e}")))?;
let json_str = result
.result
.result
.value
.and_then(|v| v.as_str().map(String::from))
.ok_or_else(|| Error::ElementNotFound("shadow element not found".into()))?;
let data: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| Error::Browser(format!("parse shadow result: {e}")))?;
let html = data["html"].as_str().unwrap_or_default().to_string();
let tag = data["tag"].as_str().unwrap_or_default().to_string();
let text = data["text"].as_str().unwrap_or_default().to_string();
let attrs: Vec<(String, String)> = data["attrs"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|item| {
let a = item.as_array()?;
Some((
a.first()?.as_str()?.to_string(),
a.get(1)?.as_str()?.to_string(),
))
})
.collect()
})
.unwrap_or_default();
Ok(Element::new_cdp(
page.clone(),
String::new(),
Some(Locator::Css(selector.to_string())),
html,
tag,
text,
attrs,
None,
))
}
pub async fn shadow_eles(&self, selector: &str) -> Result<Vec<Element>> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("shadow_eles requires Chromium mode".into()))?;
let oid = self
.object_id
.as_deref()
.ok_or(Error::Browser("no object_id for element".into()))?;
let parts: Vec<&str> = selector.split(">>>").map(|s| s.trim()).collect();
if parts.is_empty() {
return Err(Error::InvalidLocator("empty selector".into()));
}
let inner_sels: Vec<String> = parts
.iter()
.map(json_escape)
.collect();
let query_body = if inner_sels.len() == 1 {
format!(
"if (!this.shadowRoot) return []; \
return this.shadowRoot.querySelectorAll({sel});",
sel = &inner_sels[0]
)
} else {
let mut body =
String::from("if (!this.shadowRoot) return []; var cur = this.shadowRoot;");
for (i, sel) in inner_sels.iter().enumerate() {
if i < inner_sels.len() - 1 {
body.push_str(&format!(
" cur = cur.querySelector({sel}); \
if (!cur || !cur.shadowRoot) return []; \
cur = cur.shadowRoot;",
sel = sel
));
} else {
body.push_str(&format!(" return cur.querySelectorAll({sel});", sel = sel));
}
}
body
};
let full_fn = format!(
"function() {{ \
var els = (function() {{ {query_body} }}).call(this); \
if (!els || !els.length) return JSON.stringify([]); \
var results = []; \
for (var i = 0; i < els.length; i++) {{ \
var el = els[i]; \
var r = {{}}; \
r.html = el.outerHTML; \
r.tag = el.tagName.toLowerCase(); \
r.text = el.innerText || ''; \
var attrs = []; \
for (var j = 0; j < el.attributes.length; j++) {{ \
var a = el.attributes[j]; \
attrs.push([a.name, a.value]); \
}} \
r.attrs = attrs; \
results.push(r); \
}} \
return JSON.stringify(results); \
}}"
);
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let params = CallFunctionOnParams::builder()
.object_id(oid.to_string())
.function_declaration(full_fn)
.return_by_value(true)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
let result = page
.execute(params)
.await
.map_err(|e| Error::Browser(format!("shadow_eles: {e}")))?;
let json_str = result
.result
.result
.value
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "[]".to_string());
let items: Vec<serde_json::Value> = serde_json::from_str(&json_str)
.map_err(|e| Error::Browser(format!("parse shadow results: {e}")))?;
let mut elements = Vec::with_capacity(items.len());
for data in &items {
let html = data["html"].as_str().unwrap_or_default().to_string();
let tag = data["tag"].as_str().unwrap_or_default().to_string();
let text = data["text"].as_str().unwrap_or_default().to_string();
let attrs: Vec<(String, String)> = data["attrs"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|item| {
let a = item.as_array()?;
Some((
a.first()?.as_str()?.to_string(),
a.get(1)?.as_str()?.to_string(),
))
})
.collect()
})
.unwrap_or_default();
elements.push(Element::new_cdp(
page.clone(),
String::new(),
Some(Locator::Css(selector.to_string())),
html,
tag,
text,
attrs,
None,
));
}
Ok(elements)
}
async fn requery(&self) -> Result<Element> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("wait requires Chromium mode".into()))?;
let locator = self
.locator
.as_ref()
.ok_or(Error::Browser("no locator for element".into()))?;
let selector = locator_to_selector(locator)?;
let cdp_el = page
.find_element(&selector)
.await
.map_err(|e| Error::Browser(format!("re-query: {e}")))?;
let html = cdp_el.outer_html().await.ok().flatten().unwrap_or_default();
let text = cdp_el.inner_text().await.ok().flatten().unwrap_or_default();
let tag = cdp_el
.string_property("tagName")
.await
.ok()
.flatten()
.unwrap_or_default()
.to_lowercase();
let attrs: Vec<(String, String)> = cdp_el
.call_js_fn(
"function(){ var r=[]; for(var i=0;i<this.attributes.length;i++){var a=this.attributes[i]; r.push([a.name,a.value]);} return JSON.stringify(r); }",
false,
)
.await
.ok()
.and_then(|r| {
r.result.value.and_then(|v| serde_json::from_value(v).ok())
})
.unwrap_or_default();
let object_id: String = cdp_el.remote_object_id.clone().into();
Ok(Element::new_cdp(
page.clone(),
object_id,
Some(locator.clone()),
html,
tag,
text,
attrs,
None,
))
}
pub async fn wait_for_visible(&self) -> Result<()> {
self.wait_for_visible_with_options(WaitOptions::default()).await
}
pub async fn wait_for_visible_with_timeout(&self, timeout: Duration) -> Result<()> {
self.wait_for_visible_with_options(WaitOptions::default().timeout(timeout))
.await
}
pub async fn wait_for_visible_with_options(&self, opts: WaitOptions) -> Result<()> {
let start = std::time::Instant::now();
loop {
match self.requery().await {
Ok(el) => {
if el.is_visible().await {
return Ok(());
}
}
Err(_) => {
}
}
if start.elapsed() >= opts.timeout {
return Err(Error::Timeout(format!(
"Timed out waiting for element to be visible ({:?})",
opts.timeout
)));
}
tokio::time::sleep(opts.poll_interval).await;
}
}
pub async fn wait_for_hidden(&self) -> Result<()> {
self.wait_for_hidden_with_options(WaitOptions::default()).await
}
pub async fn wait_for_hidden_with_timeout(&self, timeout: Duration) -> Result<()> {
self.wait_for_hidden_with_options(WaitOptions::default().timeout(timeout))
.await
}
pub async fn wait_for_hidden_with_options(&self, opts: WaitOptions) -> Result<()> {
let start = std::time::Instant::now();
loop {
match self.requery().await {
Ok(el) => {
if !el.is_visible().await {
return Ok(());
}
}
Err(_) => {
return Ok(());
}
}
if start.elapsed() >= opts.timeout {
return Err(Error::Timeout(format!(
"Timed out waiting for element to be hidden ({:?})",
opts.timeout
)));
}
tokio::time::sleep(opts.poll_interval).await;
}
}
pub async fn wait_for_enabled(&self) -> Result<()> {
self.wait_for_enabled_with_options(WaitOptions::default()).await
}
pub async fn wait_for_enabled_with_timeout(&self, timeout: Duration) -> Result<()> {
self.wait_for_enabled_with_options(WaitOptions::default().timeout(timeout))
.await
}
pub async fn wait_for_enabled_with_options(&self, opts: WaitOptions) -> Result<()> {
let start = std::time::Instant::now();
loop {
match self.requery().await {
Ok(el) => {
if el.is_enabled() {
return Ok(());
}
}
Err(_) => {
}
}
if start.elapsed() >= opts.timeout {
return Err(Error::Timeout(format!(
"Timed out waiting for element to be enabled ({:?})",
opts.timeout
)));
}
tokio::time::sleep(opts.poll_interval).await;
}
}
pub async fn wait_for_text(&self, text: &str) -> Result<()> {
self.wait_for_text_with_options(text, WaitOptions::default())
.await
}
pub async fn wait_for_text_with_timeout(&self, text: &str, timeout: Duration) -> Result<()> {
self.wait_for_text_with_options(text, WaitOptions::default().timeout(timeout))
.await
}
pub async fn wait_for_text_with_options(
&self,
text: &str,
opts: WaitOptions,
) -> Result<()> {
let start = std::time::Instant::now();
loop {
match self.requery().await {
Ok(el) => {
if el.text().contains(text) {
return Ok(());
}
}
Err(_) => {
}
}
if start.elapsed() >= opts.timeout {
return Err(Error::Timeout(format!(
"Timed out waiting for element text to contain {:?} ({:?})",
text, opts.timeout
)));
}
tokio::time::sleep(opts.poll_interval).await;
}
}
pub async fn wait_for_attribute(&self, name: &str, value: &str) -> Result<()> {
self.wait_for_attribute_with_options(name, value, WaitOptions::default())
.await
}
pub async fn wait_for_attribute_with_timeout(
&self,
name: &str,
value: &str,
timeout: Duration,
) -> Result<()> {
self.wait_for_attribute_with_options(name, value, WaitOptions::default().timeout(timeout))
.await
}
pub async fn wait_for_attribute_with_options(
&self,
name: &str,
value: &str,
opts: WaitOptions,
) -> Result<()> {
let start = std::time::Instant::now();
loop {
match self.requery().await {
Ok(el) => {
if el.attr(name) == Some(value) {
return Ok(());
}
}
Err(_) => {
}
}
if start.elapsed() >= opts.timeout {
return Err(Error::Timeout(format!(
"Timed out waiting for element attribute {:?}={:?} ({:?})",
name, value, opts.timeout
)));
}
tokio::time::sleep(opts.poll_interval).await;
}
}
pub async fn wait_for_stale(&self) -> Result<()> {
self.wait_for_stale_with_options(WaitOptions::default()).await
}
pub async fn wait_for_stale_with_timeout(&self, timeout: Duration) -> Result<()> {
self.wait_for_stale_with_options(WaitOptions::default().timeout(timeout))
.await
}
pub async fn wait_for_stale_with_options(&self, opts: WaitOptions) -> Result<()> {
let start = std::time::Instant::now();
loop {
match self.requery().await {
Ok(_) => {
}
Err(_) => {
return Ok(());
}
}
if start.elapsed() >= opts.timeout {
return Err(Error::Timeout(format!(
"Timed out waiting for element to become stale ({:?})",
opts.timeout
)));
}
tokio::time::sleep(opts.poll_interval).await;
}
}
pub async fn wait_for_clickable(&self) -> Result<()> {
self.wait_for_clickable_with_options(WaitOptions::default())
.await
}
pub async fn wait_for_clickable_with_timeout(&self, timeout: Duration) -> Result<()> {
self.wait_for_clickable_with_options(WaitOptions::default().timeout(timeout))
.await
}
pub async fn wait_for_clickable_with_options(&self, opts: WaitOptions) -> Result<()> {
let start = std::time::Instant::now();
loop {
match self.requery().await {
Ok(el) => {
if el.is_visible().await && el.is_enabled() {
return Ok(());
}
}
Err(_) => {
}
}
if start.elapsed() >= opts.timeout {
return Err(Error::Timeout(format!(
"Timed out waiting for element to be clickable ({:?})",
opts.timeout
)));
}
tokio::time::sleep(opts.poll_interval).await;
}
}
pub async fn wait_for_text_eq(&self, text: &str) -> Result<()> {
self.wait_for_text_eq_with_options(text, WaitOptions::default())
.await
}
pub async fn wait_for_text_eq_with_timeout(&self, text: &str, timeout: Duration) -> Result<()> {
self.wait_for_text_eq_with_options(text, WaitOptions::default().timeout(timeout))
.await
}
pub async fn wait_for_text_eq_with_options(
&self,
text: &str,
opts: WaitOptions,
) -> Result<()> {
let start = std::time::Instant::now();
loop {
match self.requery().await {
Ok(el) => {
if el.text() == text {
return Ok(());
}
}
Err(_) => {
}
}
if start.elapsed() >= opts.timeout {
return Err(Error::Timeout(format!(
"Timed out waiting for element text to equal {:?} ({:?})",
text, opts.timeout
)));
}
tokio::time::sleep(opts.poll_interval).await;
}
}
pub async fn wait_for_attribute_contains(&self, name: &str, value: &str) -> Result<()> {
self.wait_for_attribute_contains_with_options(name, value, WaitOptions::default())
.await
}
pub async fn wait_for_attribute_contains_with_timeout(
&self,
name: &str,
value: &str,
timeout: Duration,
) -> Result<()> {
self.wait_for_attribute_contains_with_options(
name,
value,
WaitOptions::default().timeout(timeout),
)
.await
}
pub async fn wait_for_attribute_contains_with_options(
&self,
name: &str,
value: &str,
opts: WaitOptions,
) -> Result<()> {
let start = std::time::Instant::now();
loop {
match self.requery().await {
Ok(el) => {
if el.attr(name).is_some_and(|v| v.contains(value)) {
return Ok(());
}
}
Err(_) => {
}
}
if start.elapsed() >= opts.timeout {
return Err(Error::Timeout(format!(
"Timed out waiting for element attribute {:?} to contain {:?} ({:?})",
name, value, opts.timeout
)));
}
tokio::time::sleep(opts.poll_interval).await;
}
}
pub async fn remove(&self) -> Result<()> {
self.js("this.remove()").await
}
pub async fn set_style(&self, property: &str, value: &str) -> Result<()> {
let escaped_prop = json_escape(property);
let escaped_val = json_escape(value);
let js = format!("this.style[{}] = {}", escaped_prop, escaped_val);
self.js(&js).await
}
pub async fn get_style(&self, property: &str) -> Result<String> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("requires Chromium mode".into()))?;
if let Some(ref oid) = self.object_id {
if !oid.is_empty() {
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let escaped = json_escape(property);
let function_decl = format!(
"function() {{ return getComputedStyle(this)[{}]; }}",
escaped
);
let params = CallFunctionOnParams::builder()
.object_id(oid.clone())
.function_declaration(function_decl)
.await_promise(false)
.return_by_value(true)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
let result = page
.execute(params)
.await
.map_err(|e| Error::Browser(format!("get_style: {e}")))?;
if let Some(val) = result.result.result.value {
Ok(val.as_str().unwrap_or("").to_string())
} else {
Ok(String::new())
}
} else {
Ok(String::new())
}
} else {
Ok(String::new())
}
}
pub async fn add_class(&self, class: &str) -> Result<()> {
let escaped = json_escape(class);
let js = format!("this.classList.add({})", escaped);
self.js(&js).await
}
pub async fn remove_class(&self, class: &str) -> Result<()> {
let escaped = json_escape(class);
let js = format!("this.classList.remove({})", escaped);
self.js(&js).await
}
pub async fn has_class(&self, class: &str) -> Result<bool> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("requires Chromium mode".into()))?;
if let Some(ref oid) = self.object_id {
if !oid.is_empty() {
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let escaped = json_escape(class);
let function_decl = format!(
"function() {{ return this.classList.contains({}); }}",
escaped
);
let params = CallFunctionOnParams::builder()
.object_id(oid.clone())
.function_declaration(function_decl)
.await_promise(false)
.return_by_value(true)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
let result = page
.execute(params)
.await
.map_err(|e| Error::Browser(format!("has_class: {e}")))?;
Ok(result
.result
.result
.value
.and_then(|v| v.as_bool())
.unwrap_or(false))
} else {
Ok(false)
}
} else {
Ok(false)
}
}
pub async fn set_value(&self, value: &str) -> Result<()> {
let escaped = json_escape(value);
let js = format!(
"(function() {{ \
var proto = Object.getPrototypeOf(this); \
var desc = null; \
while (proto) {{ \
desc = Object.getOwnPropertyDescriptor(proto, 'value'); \
if (desc) break; \
proto = Object.getPrototypeOf(proto); \
}} \
if (desc && desc.set) {{ \
desc.set.call(this, {}); \
this.dispatchEvent(new Event('input', {{bubbles: true}})); \
this.dispatchEvent(new Event('change', {{bubbles: true}})); \
}} \
}}).call(this);",
escaped
);
self.js(&js).await
}
pub async fn get_rect(&self) -> Result<(f64, f64, f64, f64)> {
let page = self
.page
.as_ref()
.ok_or(Error::Browser("requires Chromium mode".into()))?;
if let Some(ref oid) = self.object_id {
if !oid.is_empty() {
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
let function_decl = "function(){\
var r=this.getBoundingClientRect();\
return [r.x,r.y,r.width,r.height]\
}";
let params = CallFunctionOnParams::builder()
.object_id(oid.clone())
.function_declaration(function_decl.to_string())
.return_by_value(true)
.build()
.map_err(|e| Error::Browser(format!("build: {e}")))?;
let result = page
.execute(params)
.await
.map_err(|e| Error::Browser(format!("get_rect: {e}")))?;
let arr = result
.result
.result
.value
.ok_or_else(|| Error::Browser("get_rect: no value".into()))?;
let vals: Vec<f64> = arr
.as_array()
.ok_or_else(|| Error::Browser("get_rect: not array".into()))?
.iter()
.filter_map(|v| v.as_f64())
.collect();
if vals.len() >= 4 {
Ok((vals[0], vals[1], vals[2], vals[3]))
} else {
Err(Error::Browser("get_rect: insufficient values".into()))
}
} else {
Err(Error::Browser("get_rect: empty object_id".into()))
}
} else {
Err(Error::Browser("get_rect: no object_id".into()))
}
}
}
pub(crate) fn from_scraper_element(
el: &scraper::ElementRef,
locator: Option<Locator>,
page: Option<Page>,
) -> Element {
let tag = el.value().name().to_string();
let attrs: Vec<(String, String)> = el
.value()
.attrs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let text = el.text().collect::<Vec<_>>().join("");
let html = el.html();
if let Some(p) = page {
Element::new_cdp(
p,
String::new(), locator,
html,
tag,
text,
attrs,
None,
)
} else {
Element::new_session(locator, html, tag, text, attrs)
}
}
pub trait ElementBatch {
fn texts(&self) -> Vec<&str>;
fn attr_values(&self, name: &str) -> Vec<Option<&str>>;
fn displayed(&self) -> Vec<&Element>;
}
impl ElementBatch for [Element] {
fn texts(&self) -> Vec<&str> {
self.iter().map(|e| e.text()).collect()
}
fn attr_values(&self, name: &str) -> Vec<Option<&str>> {
self.iter().map(|e| e.attr(name)).collect()
}
fn displayed(&self) -> Vec<&Element> {
self.iter().filter(|e| e.is_displayed()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_session_basic() {
let el = Element::new_session(
None,
"<button class=\"btn\">Click me</button>".to_string(),
"button".to_string(),
"Click me".to_string(),
vec![("class".to_string(), "btn".to_string())],
);
assert_eq!(el.tag(), "button");
assert_eq!(el.text(), "Click me");
assert_eq!(el.html(), "<button class=\"btn\">Click me</button>");
assert_eq!(el.attr("class"), Some("btn"));
assert!(matches!(el.page_ref(), PageRef::Session));
assert!(!el.is_cdp());
}
#[test]
fn test_new_session_no_locator() {
let el = Element::new_session(
None,
"<div></div>".to_string(),
"div".to_string(),
String::new(),
vec![],
);
assert!(el.locator().is_none());
}
#[test]
fn test_new_session_with_locator() {
let loc = Locator::Css("#test".to_string());
let el = Element::new_session(
Some(loc),
"<div id=\"test\"></div>".to_string(),
"div".to_string(),
String::new(),
vec![("id".to_string(), "test".to_string())],
);
assert!(el.locator().is_some());
assert_eq!(el.locator().unwrap(), &Locator::Css("#test".to_string()));
}
#[test]
fn test_tag_accessor() {
let el = Element::new_session(
None,
"<input type=\"text\" />".to_string(),
"input".to_string(),
String::new(),
vec![("type".to_string(), "text".to_string())],
);
assert_eq!(el.tag(), "input");
}
#[test]
fn test_text_accessor() {
let el = Element::new_session(
None,
"<span>Hello World</span>".to_string(),
"span".to_string(),
"Hello World".to_string(),
vec![],
);
assert_eq!(el.text(), "Hello World");
}
#[test]
fn test_html_accessor() {
let html = "<div class=\"outer\"><span>inner</span></div>";
let el = Element::new_session(
None,
html.to_string(),
"div".to_string(),
"inner".to_string(),
vec![("class".to_string(), "outer".to_string())],
);
assert_eq!(el.html(), html);
}
#[test]
fn test_attr_existing() {
let el = Element::new_session(
None,
"<a href=\"/login\" class=\"link\">Login</a>".to_string(),
"a".to_string(),
"Login".to_string(),
vec![
("href".to_string(), "/login".to_string()),
("class".to_string(), "link".to_string()),
],
);
assert_eq!(el.attr("href"), Some("/login"));
assert_eq!(el.attr("class"), Some("link"));
}
#[test]
fn test_attr_missing() {
let el = Element::new_session(
None,
"<div></div>".to_string(),
"div".to_string(),
String::new(),
vec![],
);
assert_eq!(el.attr("id"), None);
}
#[test]
fn test_attr_case_insensitive() {
let el = Element::new_session(
None,
"<div Class=\"upper\"></div>".to_string(),
"div".to_string(),
String::new(),
vec![("Class".to_string(), "upper".to_string())],
);
assert_eq!(el.attr("class"), Some("upper"));
assert_eq!(el.attr("CLASS"), Some("upper"));
}
#[test]
fn test_attrs_all() {
let el = Element::new_session(
None,
"<input type=\"text\" name=\"q\" />".to_string(),
"input".to_string(),
String::new(),
vec![
("type".to_string(), "text".to_string()),
("name".to_string(), "q".to_string()),
],
);
let attrs = el.attrs();
assert_eq!(attrs.len(), 2);
assert_eq!(attrs[0].0, "type");
assert_eq!(attrs[1].0, "name");
}
#[test]
fn test_page_ref_session() {
let el = Element::new_session(
None,
"<p></p>".to_string(),
"p".to_string(),
String::new(),
vec![],
);
assert!(matches!(el.page_ref(), PageRef::Session));
}
#[test]
fn test_is_cdp_session_element() {
let el = Element::new_session(
None,
"<div></div>".to_string(),
"div".to_string(),
String::new(),
vec![],
);
assert!(!el.is_cdp());
}
#[test]
fn test_is_enabled_no_disabled() {
let el = Element::new_session(
None,
"<button>Click</button>".to_string(),
"button".to_string(),
"Click".to_string(),
vec![],
);
assert!(el.is_enabled());
}
#[test]
fn test_is_enabled_with_disabled() {
let el = Element::new_session(
None,
"<button disabled>Click</button>".to_string(),
"button".to_string(),
"Click".to_string(),
vec![("disabled".to_string(), String::new())],
);
assert!(!el.is_enabled());
}
#[test]
fn test_is_displayed_normal() {
let el = Element::new_session(
None,
"<div>Hello</div>".to_string(),
"div".to_string(),
"Hello".to_string(),
vec![],
);
assert!(el.is_displayed());
}
#[test]
fn test_is_displayed_empty_html() {
let el = Element::new_session(
None,
String::new(),
String::new(),
String::new(),
vec![],
);
assert!(!el.is_displayed());
}
#[test]
fn test_is_displayed_hidden() {
let el = Element::new_session(
None,
"<div style=\"display:none\">Hidden</div>".to_string(),
"div".to_string(),
"Hidden".to_string(),
vec![],
);
assert!(!el.is_displayed());
}
#[test]
fn test_batch_texts() {
let elements: Vec<Element> = vec![
Element::new_session(None, "<p>a</p>".into(), "p".into(), "a".into(), vec![]),
Element::new_session(None, "<p>b</p>".into(), "p".into(), "b".into(), vec![]),
];
assert_eq!(elements.texts(), vec!["a", "b"]);
}
#[test]
fn test_batch_attr_values() {
let elements: Vec<Element> = vec![
Element::new_session(
None,
"<a href=\"/a\">a</a>".into(),
"a".into(),
"a".into(),
vec![("href".into(), "/a".into())],
),
Element::new_session(
None,
"<a>no href</a>".into(),
"a".into(),
"no href".into(),
vec![],
),
];
assert_eq!(elements.attr_values("href"), vec![Some("/a"), None]);
}
}