use std::fmt::Write as _;
use std::sync::Arc;
use crate::actions;
use crate::backend::AnyElement;
use crate::error::Result;
use crate::options::{BoundingBox, FilterOptions, RoleOptions, StringOrRegex, TextOptions, WaitOptions};
use crate::selectors;
macro_rules! retry_resolve {
($self:expr, $timeout_ms:expr, $op:expr, |$el:ident, $page:ident| $body:expr) => {{
let (__rframe, __rsel) = $self.resolved().await.map_err($crate::error::FerriError::from)?;
let $page: &$crate::backend::AnyPage = __rframe.page_arc().inner();
$page
.ensure_engine_injected()
.await
.map_err($crate::error::FerriError::from)?;
let __fd = "window.__fd";
let __sel_js =
$crate::selectors::build_selone_js(&__rsel, &__fd, $self.strict).map_err($crate::error::FerriError::from)?;
let __frame_id: ::std::option::Option<&str> = if __rframe.is_main_frame() {
::std::option::Option::None
} else {
::std::option::Option::Some(__rframe.id())
};
let __op_name: &str = $op;
let __resolved_timeout: u64 = $timeout_ms.unwrap_or_else(|| $self.frame.page_arc().default_timeout());
let __deadline: ::std::option::Option<::std::time::Instant> = if __resolved_timeout == 0 {
::std::option::Option::None
} else {
::std::option::Option::Some(::std::time::Instant::now() + ::std::time::Duration::from_millis(__resolved_timeout))
};
let mut __idx: usize = 0;
loop {
if let ::std::option::Option::Some(__d) = __deadline {
if ::std::time::Instant::now() >= __d {
return ::std::result::Result::Err($crate::error::FerriError::timeout(
__op_name.to_string(),
__resolved_timeout,
));
}
}
let __delay_ms = Locator::RETRY_BACKOFFS_MS[__idx.min(Locator::RETRY_BACKOFFS_MS.len() - 1)];
__idx = __idx.saturating_add(1);
if __delay_ms > 0 {
let __sleep_ms = match __deadline {
::std::option::Option::Some(__d) => {
let __left = u64::try_from(__d.saturating_duration_since(::std::time::Instant::now()).as_millis())
.unwrap_or(__delay_ms);
__delay_ms.min(__left)
},
::std::option::Option::None => __delay_ms,
};
if __sleep_ms > 0 {
::tokio::time::sleep(::std::time::Duration::from_millis(__sleep_ms)).await;
}
}
match $crate::selectors::query_one_prebuilt($page, &__sel_js, &$self.selector, __frame_id).await {
::std::result::Result::Ok($el) => match ($body).await {
::std::result::Result::Ok(val) => return ::std::result::Result::Ok(val),
::std::result::Result::Err(e) => {
let __msg = e.to_string();
if __msg.contains("not connected")
|| __msg.contains("not found")
|| __msg.contains("detached")
|| __msg.contains("error:not")
{
} else {
return ::std::result::Result::Err($crate::error::FerriError::from(e));
}
},
},
::std::result::Result::Err(__err) => {
if let ::std::option::Option::Some(__count) = $crate::selectors::parse_strict_violation_count(&__err) {
return ::std::result::Result::Err($crate::error::FerriError::strict($self.selector.clone(), __count));
}
},
}
}
}};
}
#[derive(Clone)]
pub struct Locator {
pub(crate) frame: crate::frame::Frame,
pub(crate) selector: String,
pub(crate) strict: bool,
}
impl Locator {
#[must_use]
pub(crate) fn new(frame: crate::frame::Frame, selector: String) -> Self {
Self {
frame,
selector,
strict: true,
}
}
pub(crate) async fn resolved(&self) -> Result<(crate::frame::Frame, String)> {
const MARK: &str = ">> internal:control=enter-frame >>";
if !self.selector.contains("internal:control=enter-frame") {
return Ok((self.frame.clone(), self.selector.clone()));
}
let mut parts = self.selector.split(MARK).map(str::trim);
let mut cur = self.frame.clone();
let mut pending = parts.next().unwrap_or("").to_string();
for next in parts {
let page_arc = std::sync::Arc::clone(cur.page_arc());
let fid: Option<String> = if cur.is_main_frame() {
None
} else {
Some(cur.id().to_string())
};
let el = crate::selectors::query_one(page_arc.inner(), &pending, false, fid.as_deref()).await?;
let handle = crate::element_handle::ElementHandle::from_any_element(std::sync::Arc::clone(&page_arc), el).await?;
cur = handle
.content_frame()
.await?
.ok_or_else(|| crate::error::FerriError::protocol("frameLocator", "<iframe> has no content frame"))?;
pending = next.to_string();
}
Ok((cur, pending))
}
#[must_use]
pub fn strict(&self, strict: bool) -> Locator {
Locator {
frame: self.frame.clone(),
selector: self.selector.clone(),
strict,
}
}
#[must_use]
pub fn locator(
&self,
selector_or_locator: impl Into<crate::options::LocatorLike>,
options: Option<crate::options::FilterOptions>,
) -> Locator {
let inner = selector_or_locator.into();
let base = match &inner {
crate::options::LocatorLike::Selector(s) => self.chain(s),
crate::options::LocatorLike::Locator(l) => {
if Arc::ptr_eq(self.frame.page_arc(), l.frame.page_arc()) {
self.chain(&format!("internal:chain={}", json_quote(&l.selector)))
} else {
self.chain("internal:cross-frame-error=true")
}
},
};
match options {
Some(mut opts) => {
opts.visible = None; base.filter(&opts)
},
None => base,
}
}
#[must_use]
pub fn get_by_role(&self, role: &str, opts: &RoleOptions) -> Locator {
self.chain(&build_role_selector(role, opts))
}
#[must_use]
pub fn get_by_text(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.chain(&build_text_like_selector("internal:text", text, opts))
}
#[must_use]
pub fn get_by_label(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.chain(&build_text_like_selector("internal:label", text, opts))
}
#[must_use]
pub fn get_by_placeholder(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.chain(&build_attr_selector("placeholder", text, opts))
}
#[must_use]
pub fn get_by_alt_text(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.chain(&build_attr_selector("alt", text, opts))
}
#[must_use]
pub fn get_by_title(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.chain(&build_attr_selector("title", text, opts))
}
#[must_use]
pub fn get_by_test_id(&self, test_id: &StringOrRegex) -> Locator {
self.chain(&build_testid_selector("data-testid", test_id))
}
#[must_use]
pub fn first(&self) -> Locator {
self.chain("nth=0").strict(false)
}
#[must_use]
pub fn last(&self) -> Locator {
self.chain("nth=-1").strict(false)
}
#[must_use]
pub fn nth(&self, index: i32) -> Locator {
self.chain(&format!("nth={index}")).strict(false)
}
#[must_use]
pub fn filter(&self, opts: &FilterOptions) -> Locator {
use std::fmt::Write as _;
let mut suffix = String::new();
let push_sep = |buf: &mut String| {
if !buf.is_empty() {
buf.push_str(" >> ");
}
};
if let Some(text) = &opts.has_text {
let _ = write!(suffix, "internal:has-text={}", json_quote(text));
}
if let Some(text) = &opts.has_not_text {
push_sep(&mut suffix);
let _ = write!(suffix, "internal:has-not-text={}", json_quote(text));
}
if let Some(inner) = &opts.has {
push_sep(&mut suffix);
if inner
.as_locator()
.is_some_and(|l| !Arc::ptr_eq(self.frame.page_arc(), l.frame.page_arc()))
{
let _ = write!(suffix, "internal:has-cross-frame-error=true");
} else {
let _ = write!(suffix, "internal:has={}", json_quote(inner.as_selector()));
}
}
if let Some(inner) = &opts.has_not {
push_sep(&mut suffix);
if inner
.as_locator()
.is_some_and(|l| !Arc::ptr_eq(self.frame.page_arc(), l.frame.page_arc()))
{
let _ = write!(suffix, "internal:has-not-cross-frame-error=true");
} else {
let _ = write!(suffix, "internal:has-not={}", json_quote(inner.as_selector()));
}
}
if let Some(v) = opts.visible {
push_sep(&mut suffix);
let _ = write!(suffix, "visible={}", if v { "true" } else { "false" });
}
if suffix.is_empty() {
self.clone()
} else {
self.chain(&suffix)
}
}
pub async fn click(&self, opts: Option<crate::options::ClickOptions>) -> Result<()> {
let opts = opts.unwrap_or_default();
let opts_ref = &opts;
retry_resolve!(self, opts_ref.timeout, "click", |el, page| async move {
actions::click_with_opts(&el, page, opts_ref).await
})
}
pub async fn dblclick(&self, opts: Option<crate::options::DblClickOptions>) -> Result<()> {
let click_opts = opts.unwrap_or_default().into_click_options();
let click_opts_ref = &click_opts;
retry_resolve!(self, click_opts_ref.timeout, "dblclick", |el, page| async move {
actions::click_with_opts(&el, page, click_opts_ref).await
})
}
pub async fn right_click(&self) -> Result<()> {
retry_resolve!(
self,
::std::option::Option::<u64>::None,
"right_click",
|el, page| async move {
let center = el.call_js_fn_value(
"function() { this.scrollIntoViewIfNeeded(); var r = this.getBoundingClientRect(); return {x: r.x + r.width/2, y: r.y + r.height/2}; }"
).await?;
if let Some(c) = center {
let x = c.get("x").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
let y = c.get("y").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
page.click_at_opts(x, y, "right", 1).await?;
}
Ok::<(), crate::error::FerriError>(())
}
)
}
pub async fn fill(&self, value: &str, opts: Option<crate::options::FillOptions>) -> Result<()> {
let opts = opts.unwrap_or_default();
let force = opts.is_force();
let opts_ref = &opts;
retry_resolve!(self, opts_ref.timeout, "fill", |el, page| async move {
actions::fill(&el, page, value, force).await
})
}
pub async fn clear(&self) -> Result<()> {
retry_resolve!(
self,
::std::option::Option::<u64>::None,
"clear",
|el, _page| async move {
el.call_js_fn(
"function() { \
if (window.__fd) window.__fd.clearAndDispatch(this); \
else { this.value = ''; } \
}",
)
.await?;
Ok::<(), crate::error::FerriError>(())
}
)
}
pub async fn r#type(&self, text: &str, opts: Option<crate::options::TypeOptions>) -> Result<()> {
let opts = opts.unwrap_or_default();
let delay_ms = opts.resolved_delay_ms();
let timeout_ms = opts.timeout;
retry_resolve!(self, timeout_ms, "type", |el, page| async move {
actions::wait_for_actionable(&el, page).await.ok();
if delay_ms > 0 {
for ch in text.chars() {
page.press_key(&ch.to_string()).await?;
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
Ok(())
} else {
el.type_str(text).await
}
})
}
pub async fn press(&self, key: &str, opts: Option<crate::options::PressOptions>) -> Result<()> {
let opts = opts.unwrap_or_default();
let delay_ms = opts.resolved_delay_ms();
let timeout_ms = opts.timeout;
retry_resolve!(self, timeout_ms, "press", |el, page| async move {
actions::wait_for_actionable(&el, page).await.ok();
el.call_js_fn("function() { this.focus(); }").await?;
if delay_ms > 0 {
page.key_down(key).await?;
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
page.key_up(key).await
} else {
page.press_key(key).await
}
})
}
pub async fn hover(&self, opts: Option<crate::options::HoverOptions>) -> Result<()> {
let opts = opts.unwrap_or_default();
let opts_ref = &opts;
retry_resolve!(self, opts_ref.timeout, "hover", |el, page| async move {
actions::hover_with_opts(&el, page, opts_ref).await
})
}
pub async fn focus(&self) -> Result<()> {
retry_resolve!(
self,
::std::option::Option::<u64>::None,
"focus",
|el, _page| async move {
el.call_js_fn("function() { this.focus(); }").await?;
Ok::<(), crate::error::FerriError>(())
}
)
}
pub async fn check(&self, opts: Option<crate::options::CheckOptions>) -> Result<()> {
self.set_checked(true, opts).await
}
pub async fn uncheck(&self, opts: Option<crate::options::CheckOptions>) -> Result<()> {
self.set_checked(false, opts).await
}
pub async fn set_checked(&self, checked: bool, opts: Option<crate::options::CheckOptions>) -> Result<()> {
let opts = opts.unwrap_or_default();
let trial = opts.is_trial();
let click_opts = opts.into_click_options();
let click_opts_ref = &click_opts;
retry_resolve!(self, click_opts_ref.timeout, "check", |el, page| async move {
let fd = page.injected_script().await?;
let state_js = format!(
"function() {{ \
var r = {fd}.getChecked(this); \
var isRadio = this.nodeName === 'INPUT' && this.type === 'radio'; \
return JSON.stringify({{ state: r, isRadio: isRadio }}); \
}}"
);
let read_state = async || -> crate::error::Result<(Option<bool>, bool)> {
let raw = el
.call_js_fn_value(&state_js)
.await?
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
let is_radio = parsed
.get("isRadio")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let state_val = match parsed.get("state") {
Some(v) if v.is_boolean() => Some(v.as_bool().unwrap_or(false)),
_ => None,
};
Ok((state_val, is_radio))
};
let (current, is_radio) = read_state().await?;
let Some(current) = current else {
return Err(crate::error::FerriError::invalid_argument(
"element",
"not a checkbox, radio button, or ARIA-checkable element",
));
};
if current == checked {
return Ok::<(), crate::error::FerriError>(());
}
if !checked && is_radio {
return Err(crate::error::FerriError::invalid_argument(
"element",
"Cannot uncheck radio button. Radio buttons can only be unchecked by selecting another radio button in the same group.",
));
}
actions::click_with_opts(&el, page, click_opts_ref).await?;
if trial {
return Ok::<(), crate::error::FerriError>(());
}
let (new_state, _) = read_state().await?;
if new_state != Some(checked) {
return Err(crate::error::FerriError::backend(
"clicking the checkbox did not change its state",
));
}
Ok::<(), crate::error::FerriError>(())
})
}
pub async fn tap(&self, opts: Option<crate::options::TapOptions>) -> Result<()> {
let opts = opts.unwrap_or_default();
let opts_ref = &opts;
retry_resolve!(self, opts_ref.timeout, "tap", |el, page| async move {
actions::tap_with_opts(&el, page, opts_ref).await
})
}
pub async fn select_text(&self) -> Result<()> {
let el = self.resolve().await?;
el.call_js_fn(
"function() { \
this.focus(); \
if (this.select) { this.select(); } \
else if (this.setSelectionRange) { this.setSelectionRange(0, this.value ? this.value.length : 0); } \
}",
)
.await
}
pub async fn select_option(
&self,
values: Vec<crate::options::SelectOptionValue>,
opts: Option<crate::options::SelectOptionOptions>,
) -> Result<Vec<String>> {
let opts = opts.unwrap_or_default();
let timeout_ms = opts.timeout;
let force = opts.force.unwrap_or(false);
let values_ref = &values;
retry_resolve!(self, timeout_ms, "selectOption", |el, page| async move {
if !force {
let fd = page.injected_script().await?;
let state_raw = el
.call_js_fn_value(&format!(
"function() {{ return {fd}.checkElementStates(this, ['visible', 'enabled']); }}"
))
.await?
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_else(|| "error:notconnected".to_string());
if state_raw != "done" {
return Err(crate::error::FerriError::backend(state_raw));
}
}
actions::select_options(&el, page, values_ref).await
})
}
pub async fn set_input_files(
&self,
files: crate::options::InputFiles,
_opts: Option<crate::options::SetInputFilesOptions>,
) -> Result<()> {
match files {
crate::options::InputFiles::Paths(paths) => {
let strs: Vec<String> = paths.into_iter().map(|p| p.display().to_string()).collect();
actions::upload_file(self.frame.page_arc().inner(), &self.selector, &strs).await
},
crate::options::InputFiles::Payloads(payloads) => {
let tmp_root = std::env::temp_dir().join(format!("ferridriver-files-{}", std::process::id()));
std::fs::create_dir_all(&tmp_root)
.map_err(|e| crate::error::FerriError::Backend(format!("failed to create upload temp dir: {e}")))?;
let upload_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let mut paths: Vec<String> = Vec::new();
for (i, p) in payloads.iter().enumerate() {
let sub = tmp_root.join(format!("{upload_id}-{i}"));
std::fs::create_dir_all(&sub)
.map_err(|e| crate::error::FerriError::Backend(format!("failed to create payload subdir: {e}")))?;
let safe_name = p.name.replace(['/', '\\', '\0'], "_");
let path = sub.join(&safe_name);
std::fs::write(&path, &p.buffer)
.map_err(|e| crate::error::FerriError::Backend(format!("failed to write upload payload: {e}")))?;
paths.push(path.display().to_string());
}
actions::upload_file(self.frame.page_arc().inner(), &self.selector, &paths).await
},
}
}
pub async fn scroll_into_view_if_needed(&self) -> Result<()> {
let el = self.resolve().await?;
el.scroll_into_view().await
}
pub async fn dispatch_event(
&self,
event_type: &str,
event_init: Option<serde_json::Value>,
opts: Option<crate::options::DispatchEventOptions>,
) -> Result<()> {
let timeout_ms = opts.and_then(|o| o.timeout);
let init_json = event_init.as_ref().map_or_else(
|| "{}".to_string(),
|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
);
let init_js = init_json.replace("</", "<\\/");
let js = format!(
"function() {{ \
var type = '{event_type}'; \
var init = Object.assign({{bubbles: true, cancelable: true, composed: true}}, {init_js}); \
var ev; \
if (['click','dblclick','mousedown','mouseup','mouseenter','mouseleave','mousemove','mouseover','mouseout','contextmenu','auxclick'].includes(type)) {{ \
ev = new MouseEvent(type, init); \
}} else if (['keydown','keyup','keypress'].includes(type)) {{ \
ev = new KeyboardEvent(type, init); \
}} else if (['touchstart','touchend','touchmove','touchcancel'].includes(type) && typeof TouchEvent !== 'undefined') {{ \
ev = new TouchEvent(type, init); \
}} else if (['pointerdown','pointerup','pointermove','pointerover','pointerout','pointerenter','pointerleave','pointercancel','gotpointercapture','lostpointercapture'].includes(type)) {{ \
ev = new PointerEvent(type, init); \
}} else if (['dragstart','drag','dragenter','dragleave','dragover','drop','dragend'].includes(type)) {{ \
ev = new DragEvent(type, init); \
}} else if (['focus','blur','focusin','focusout'].includes(type)) {{ \
ev = new FocusEvent(type, init); \
}} else if (['input','beforeinput'].includes(type)) {{ \
ev = new InputEvent(type, init); \
}} else if (type === 'wheel') {{ \
ev = new WheelEvent(type, init); \
}} else if (['deviceorientation','deviceorientationabsolute'].includes(type)) {{ \
ev = new DeviceOrientationEvent(type, init); \
}} else {{ \
ev = new Event(type, init); \
}} \
this.dispatchEvent(ev); \
}}"
);
let js_ref = js.as_str();
retry_resolve!(self, timeout_ms, "dispatchEvent", |el, _page| async move {
el.call_js_fn(js_ref).await
})
}
pub async fn text_content(&self) -> Result<Option<String>> {
self.eval_prop("textContent").await
}
pub async fn inner_text(&self) -> Result<String> {
self
.eval_prop("innerText")
.await
.map(std::option::Option::unwrap_or_default)
}
pub async fn inner_html(&self) -> Result<String> {
self
.eval_prop("innerHTML")
.await
.map(std::option::Option::unwrap_or_default)
}
pub async fn aria_snapshot(&self, options: crate::options::AriaSnapshotOptions) -> Result<String> {
let (root_frame, _sel) = self.resolved().await?;
let mode = options.mode.unwrap_or_default().as_str();
let depth = options.depth;
let opts_json = aria_opts_json(mode, depth, "");
let root_js =
format!("function() {{ return JSON.stringify(window.__fd.incrementalAriaSnapshot(this, {opts_json})); }}");
retry_resolve!(self, options.timeout, "ariaSnapshot", |el, _page| async {
let raw_s = el
.call_js_fn_value(&root_js)
.await?
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_default();
let raw = parse_aria_raw(&raw_s)?;
let seq = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let lines = aria_stitch_frame(root_frame.clone(), raw, mode.to_string(), depth, seq).await?;
Ok::<String, crate::error::FerriError>(lines.join("\n"))
})
}
pub async fn get_attribute(&self, name: &str) -> Result<Option<String>> {
let escaped = name.replace('\\', "\\\\").replace('\'', "\\'");
let val = self
.eval_on_element(&format!("return el.getAttribute('{escaped}');"))
.await?;
Ok(val.and_then(|v| match v {
serde_json::Value::String(s) => Some(s),
_ => None,
}))
}
pub async fn input_value(&self) -> Result<String> {
self
.eval_prop("value")
.await
.map(std::option::Option::unwrap_or_default)
}
pub async fn is_visible(&self) -> Result<bool> {
let val = self
.eval_on_element(
"var s = getComputedStyle(el); \
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';",
)
.await?;
Ok(val.and_then(|v| v.as_bool()).unwrap_or(false))
}
pub async fn is_hidden(&self) -> Result<bool> {
self.is_visible().await.map(|v| !v)
}
pub async fn is_enabled(&self) -> Result<bool> {
self.eval_bool("function() { return !this.disabled; }").await
}
pub async fn is_disabled(&self) -> Result<bool> {
self.eval_bool("function() { return !!this.disabled; }").await
}
pub async fn is_checked(&self) -> Result<bool> {
self.eval_bool("function() { return !!this.checked; }").await
}
pub async fn is_attached(&self) -> Result<bool> {
Ok(self.resolve().await.is_ok())
}
pub async fn count(&self) -> Result<usize> {
let (rf, rsel) = self.resolved().await?;
let parsed = selectors::parse(&rsel)?;
let parts_json = selectors::build_parts_json(&parsed);
let inner = rf.page_arc().inner();
let fd = inner.injected_script().await?;
let js = format!("{fd}.selCount({parts_json})");
let val = if rf.is_main_frame() {
inner.evaluate(&js).await
} else {
inner.evaluate_in_frame(&js, rf.id()).await
}?
.and_then(|v| v.as_u64())
.unwrap_or(0);
Ok(usize::try_from(val).unwrap_or(usize::MAX))
}
pub async fn bounding_box(&self) -> Result<Option<BoundingBox>> {
let val = self
.eval_on_element("var r = el.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height};")
.await?;
match val {
Some(v) => Ok(Some(BoundingBox {
x: v["x"].as_f64().unwrap_or(0.0),
y: v["y"].as_f64().unwrap_or(0.0),
width: v["width"].as_f64().unwrap_or(0.0),
height: v["height"].as_f64().unwrap_or(0.0),
})),
None => Ok(None),
}
}
pub async fn wait_for(&self, opts: WaitOptions) -> Result<()> {
let timeout = opts.timeout.unwrap_or(30000);
let state = opts.state.as_deref().unwrap_or("visible");
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout);
loop {
if tokio::time::Instant::now() >= deadline {
return Err(crate::error::FerriError::timeout(
format!("waiting for '{}' to be {state}", self.selector),
timeout,
));
}
match state {
"attached" => {
if selectors::query_one(
self.frame.page_arc().inner(),
&self.selector,
false,
if self.frame.is_main_frame() {
None
} else {
Some(self.frame.id())
},
)
.await
.is_ok()
{
selectors::cleanup_tags(self.frame.page_arc().inner()).await;
return Ok(());
}
},
"visible" => {
if let Ok(true) = self.is_visible().await {
return Ok(());
}
},
"detached" => {
if selectors::query_one(
self.frame.page_arc().inner(),
&self.selector,
false,
if self.frame.is_main_frame() {
None
} else {
Some(self.frame.id())
},
)
.await
.is_err()
{
return Ok(());
}
selectors::cleanup_tags(self.frame.page_arc().inner()).await;
},
"hidden" => {
if selectors::query_one(
self.frame.page_arc().inner(),
&self.selector,
false,
if self.frame.is_main_frame() {
None
} else {
Some(self.frame.id())
},
)
.await
.is_err()
{
return Ok(());
}
selectors::cleanup_tags(self.frame.page_arc().inner()).await;
if let Ok(false) = self.is_visible().await {
return Ok(());
}
},
_ => {
return Err(crate::error::FerriError::invalid_argument(
"state",
format!("unknown wait state: {state}"),
));
},
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
pub async fn screenshot(&self) -> Result<Vec<u8>> {
let el = self.resolve().await?;
el.screenshot(crate::backend::ImageFormat::Png).await
}
pub async fn is_editable(&self) -> Result<bool> {
self
.eval_bool("function() { return !this.disabled && !this.readOnly; }")
.await
}
pub async fn blur(&self) -> Result<()> {
let el = self.resolve().await?;
let _ = el.call_js_fn("function() { this.blur(); }").await;
Ok(())
}
pub async fn press_sequentially(&self, text: &str, opts: Option<crate::options::TypeOptions>) -> Result<()> {
self.r#type(text, opts).await
}
pub async fn drag_to(&self, target: &Locator, options: Option<crate::options::DragAndDropOptions>) -> Result<()> {
let opts = options.unwrap_or_default();
let source_el = self.resolve().await?;
let target_el = target.resolve().await?;
let (src_result, tgt_result) = tokio::join!(
source_el.call_js_fn_value(
"function() { try { this.scrollIntoViewIfNeeded(); } catch (e) { this.scrollIntoView(); } var r = this.getBoundingClientRect(); return {x: r.x, y: r.y, width: r.width, height: r.height}; }"
),
target_el.call_js_fn_value(
"function() { try { this.scrollIntoViewIfNeeded(); } catch (e) { this.scrollIntoView(); } var r = this.getBoundingClientRect(); return {x: r.x, y: r.y, width: r.width, height: r.height}; }"
),
);
let src = src_result?.ok_or_else(|| crate::error::FerriError::Backend("no source bounding box".into()))?;
let tgt = tgt_result?.ok_or_else(|| crate::error::FerriError::Backend("no target bounding box".into()))?;
let from = rect_point(&src, opts.source_position);
let to = rect_point(&tgt, opts.target_position);
if opts.trial.unwrap_or(false) {
return Ok(());
}
let steps = opts.steps.unwrap_or(1);
self.frame.page_arc().inner().click_and_drag(from, to, steps).await
}
#[must_use]
pub fn or(&self, other: &Locator) -> Locator {
self.chain(&format!(
"internal:or={}",
serde_json::to_string(&other.selector).unwrap_or_else(|_| format!("{:?}", other.selector))
))
}
#[must_use]
pub fn and(&self, other: &Locator) -> Locator {
self.chain(&format!(
"internal:and={}",
serde_json::to_string(&other.selector).unwrap_or_else(|_| format!("{:?}", other.selector))
))
}
pub async fn all(&self) -> Result<Vec<Locator>> {
let count = self.count().await?;
let mut locators = Vec::with_capacity(count);
let base = &self.selector;
for i in 0..count {
let idx = i32::try_from(i).unwrap_or(i32::MAX);
let selector = if base.is_empty() {
format!("nth={idx}")
} else {
format!("{base} >> nth={idx}")
};
locators.push(Locator {
frame: self.frame.clone(),
selector,
strict: true,
});
}
Ok(locators)
}
pub async fn all_text_contents(&self) -> Result<Vec<String>> {
let parsed = selectors::parse(&self.selector)?;
let parts_json = selectors::build_parts_json(&parsed);
self.frame.page_arc().inner().ensure_engine_injected().await?;
let fd = "window.__fd";
let js = format!(
"(function() {{ var r = {fd}._exec({parts_json}, document); \
return r.map(function(e) {{ return (e.textContent || '').trim(); }}); }})()"
);
let val = self.frame.page_arc().inner().evaluate(&js).await?;
match val {
Some(serde_json::Value::Array(arr)) => Ok(
arr
.into_iter()
.filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect(),
),
_ => Ok(Vec::new()),
}
}
pub async fn all_inner_texts(&self) -> Result<Vec<String>> {
self.all_text_contents().await
}
pub async fn evaluate(
&self,
fn_source: &str,
arg: crate::protocol::SerializedArgument,
is_function: Option<bool>,
options: Option<crate::options::EvaluateOptions>,
) -> Result<crate::protocol::SerializedValue> {
let timeout_ms = options.and_then(|o| o.timeout);
let fn_source = fn_source.to_string();
retry_resolve!(self, timeout_ms, "evaluate", |el, _page| async {
let page_arc = Arc::clone(self.frame.page_arc());
let handle = crate::element_handle::ElementHandle::from_any_element(page_arc, el).await?;
let result = handle
.as_js_handle()
.evaluate(&fn_source, arg.clone(), is_function)
.await;
let _ = handle.dispose().await;
result
})
}
pub async fn evaluate_handle(
&self,
fn_source: &str,
arg: crate::protocol::SerializedArgument,
is_function: Option<bool>,
options: Option<crate::options::EvaluateOptions>,
) -> Result<crate::js_handle::JSHandle> {
let timeout_ms = options.and_then(|o| o.timeout);
let fn_source = fn_source.to_string();
retry_resolve!(self, timeout_ms, "evaluateHandle", |el, _page| async {
let page_arc = Arc::clone(self.frame.page_arc());
let handle = crate::element_handle::ElementHandle::from_any_element(page_arc, el).await?;
let result = handle
.as_js_handle()
.evaluate_handle(&fn_source, arg.clone(), is_function)
.await;
let _ = handle.dispose().await;
result
})
}
pub async fn evaluate_all(
&self,
fn_source: &str,
arg: crate::protocol::SerializedArgument,
is_function: Option<bool>,
) -> Result<crate::protocol::SerializedValue> {
let parsed = selectors::parse(&self.selector)?;
let parts_json = selectors::build_parts_json(&parsed);
self.frame.page_arc().inner().ensure_engine_injected().await?;
let probe = format!("() => window.__fd.selAll({parts_json})");
let array_handle = self
.frame
.evaluate_handle(&probe, crate::protocol::SerializedArgument::default(), Some(true))
.await?;
let result = array_handle.evaluate(fn_source, arg, is_function).await;
let _ = array_handle.dispose().await;
result
}
async fn evaluate_in_frame_js(&self, js: &str) -> Result<Option<serde_json::Value>> {
let inner = self.frame.page_arc().inner();
if self.frame.is_main_frame() {
inner.evaluate(js).await
} else {
inner.evaluate_in_frame(js, self.frame.id()).await
}
}
#[must_use]
pub fn page(&self) -> &Arc<crate::page::Page> {
self.frame.page_arc()
}
#[must_use]
pub fn frame(&self) -> &crate::frame::Frame {
&self.frame
}
#[must_use]
pub fn content_frame(&self) -> FrameLocator {
FrameLocator::for_iframe_in(self.frame.clone(), self.selector.clone())
}
#[must_use]
pub fn frame_locator(&self, selector: &str) -> FrameLocator {
let frame_selector = if self.selector.is_empty() {
selector.to_string()
} else {
format!("{} >> {selector}", self.selector)
};
FrameLocator::for_iframe_in(self.frame.clone(), frame_selector)
}
#[must_use]
pub fn selector(&self) -> &str {
&self.selector
}
pub async fn element_handle(&self) -> crate::error::Result<crate::element_handle::ElementHandle> {
let page = self.frame.page_arc();
page.inner().ensure_engine_injected().await?;
let frame_id: Option<&str> = if self.frame.is_main_frame() {
None
} else {
Some(self.frame.id())
};
let element = crate::selectors::query_one(page.inner(), &self.selector, self.strict, frame_id).await?;
crate::element_handle::ElementHandle::from_any_element(Arc::clone(page), element).await
}
pub async fn element_handles(&self) -> crate::error::Result<Vec<crate::element_handle::ElementHandle>> {
let page = self.frame.page_arc();
page.inner().ensure_engine_injected().await?;
let frame_id: Option<&str> = if self.frame.is_main_frame() {
None
} else {
Some(self.frame.id())
};
let matches = crate::selectors::query_all(page.inner(), &self.selector, frame_id).await?;
let count = matches.len();
let mut handles = Vec::with_capacity(count);
for i in 0..count {
let tagged = format!("window.__fd.selOne([{{\"engine\":\"css\",\"body\":\"[data-fd-sel='{i}']\"}}])");
match page.inner().evaluate_to_element(&tagged, frame_id).await {
Ok(element) => {
handles.push(crate::element_handle::ElementHandle::from_any_element(Arc::clone(page), element).await?);
},
Err(err) => {
crate::selectors::cleanup_tags(page.inner()).await;
return Err(err);
},
}
}
crate::selectors::cleanup_tags(page.inner()).await;
Ok(handles)
}
#[must_use]
pub fn is_strict(&self) -> bool {
self.strict
}
const RETRY_BACKOFFS_MS: &'static [u64] = &[0, 0, 20, 50, 100, 100, 500];
async fn retry_eval_on_element(&self, js_body: &str) -> Result<Option<serde_json::Value>> {
for (i, &delay_ms) in Self::RETRY_BACKOFFS_MS.iter().enumerate() {
if delay_ms > 0 {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
let attempt: Result<Option<serde_json::Value>> = async {
let (rf, rsel) = self.resolved().await?;
let parsed = selectors::parse(&rsel)?;
let parts_json = selectors::build_parts_json(&parsed);
let inner = rf.page_arc().inner();
inner.ensure_engine_injected().await?;
let fd = "window.__fd";
let js = format!("(function() {{ var el = {fd}.selOne({parts_json}); if (!el) return null; {js_body} }})()");
if rf.is_main_frame() {
inner.evaluate(&js).await
} else {
inner.evaluate_in_frame(&js, rf.id()).await
}
}
.await;
match attempt {
Ok(Some(serde_json::Value::Null) | None) | Err(_) if i < Self::RETRY_BACKOFFS_MS.len() - 1 => {},
Ok(val) => return Ok(val),
Err(e) => return Err(e),
}
}
Ok(None)
}
pub async fn resolve(&self) -> Result<AnyElement> {
self.frame.page_arc().inner().ensure_engine_injected().await?;
let fd = "window.__fd";
let sel_js = selectors::build_selone_js(&self.selector, fd, self.strict)?;
let frame_id: Option<&str> = if self.frame.is_main_frame() {
None
} else {
Some(self.frame.id())
};
selectors::query_one_prebuilt(self.frame.page_arc().inner(), &sel_js, &self.selector, frame_id).await
}
fn chain(&self, sub: &str) -> Locator {
let selector = if self.selector.is_empty() {
sub.to_string()
} else {
format!("{} >> {sub}", self.selector)
};
Locator {
frame: self.frame.clone(),
selector,
strict: self.strict,
}
}
async fn eval_prop(&self, prop: &str) -> Result<Option<String>> {
let val = self
.retry_eval_on_element(&format!("var v = el.{prop}; return v == null ? null : String(v);"))
.await?;
Ok(val.and_then(|v| match v {
serde_json::Value::String(s) => Some(s),
serde_json::Value::Null => None,
other => Some(other.to_string()),
}))
}
async fn eval_bool(&self, func: &str) -> Result<bool> {
let val = self
.retry_eval_on_element(&format!("return !!({func}).call(el);"))
.await?;
Ok(val.and_then(|v| v.as_bool()).unwrap_or(false))
}
async fn eval_on_element(&self, js_body: &str) -> Result<Option<serde_json::Value>> {
let parsed = selectors::parse(&self.selector)?;
let parts_json = selectors::build_parts_json(&parsed);
self.frame.page_arc().inner().ensure_engine_injected().await?;
let fd = "window.__fd";
let js = format!("(function() {{ var el = {fd}.selOne({parts_json}); if (!el) return null; {js_body} }})()");
self.evaluate_in_frame_js(&js).await
}
}
impl std::fmt::Debug for Locator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Locator")
.field("selector", &self.selector)
.field("frame", &self.frame)
.field("strict", &self.strict)
.finish()
}
}
#[derive(Clone)]
pub struct FrameLocator {
frame: crate::frame::Frame,
frame_selector: String,
}
impl FrameLocator {
#[must_use]
pub fn for_iframe_in(parent_frame: crate::frame::Frame, iframe_selector: String) -> Self {
Self {
frame: parent_frame,
frame_selector: iframe_selector,
}
}
fn enter(&self, selector: &str) -> String {
format!("{} >> internal:control=enter-frame >> {selector}", self.frame_selector)
}
#[must_use]
pub fn locator(&self, selector: &str, options: Option<crate::options::FilterOptions>) -> Locator {
let base = Locator::new(self.frame.clone(), self.enter(selector));
match options {
Some(opts) => base.filter(&opts),
None => base,
}
}
#[must_use]
pub fn get_by_role(&self, role: &str, opts: &RoleOptions) -> Locator {
self.locator(&build_role_selector(role, opts), None)
}
#[must_use]
pub fn get_by_text(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.locator(&build_text_like_selector("internal:text", text, opts), None)
}
#[must_use]
pub fn get_by_test_id(&self, test_id: &StringOrRegex) -> Locator {
self.locator(&build_testid_selector("data-testid", test_id), None)
}
#[must_use]
pub fn get_by_label(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.locator(&build_text_like_selector("internal:label", text, opts), None)
}
#[must_use]
pub fn get_by_placeholder(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.locator(&build_attr_selector("placeholder", text, opts), None)
}
#[must_use]
pub fn get_by_alt_text(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.locator(&build_attr_selector("alt", text, opts), None)
}
#[must_use]
pub fn get_by_title(&self, text: &StringOrRegex, opts: &TextOptions) -> Locator {
self.locator(&build_attr_selector("title", text, opts), None)
}
#[must_use]
pub fn owner(&self) -> Locator {
Locator::new(self.frame.clone(), self.frame_selector.clone())
}
#[must_use]
pub fn frame_locator(&self, selector: &str) -> FrameLocator {
FrameLocator {
frame: self.frame.clone(),
frame_selector: self.enter(selector),
}
}
#[must_use]
pub fn first(&self) -> FrameLocator {
FrameLocator {
frame: self.frame.clone(),
frame_selector: format!("{} >> nth=0", self.frame_selector),
}
}
#[must_use]
pub fn last(&self) -> FrameLocator {
FrameLocator {
frame: self.frame.clone(),
frame_selector: format!("{} >> nth=-1", self.frame_selector),
}
}
#[must_use]
pub fn nth(&self, index: i32) -> FrameLocator {
FrameLocator {
frame: self.frame.clone(),
frame_selector: format!("{} >> nth={index}", self.frame_selector),
}
}
}
impl std::fmt::Debug for FrameLocator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FrameLocator")
.field("frame_selector", &self.frame_selector)
.field("frame", &self.frame)
.finish()
}
}
fn rect_point(rect: &serde_json::Value, position: Option<crate::options::Point>) -> (f64, f64) {
let x = rect.get("x").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
let y = rect.get("y").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
let width = rect.get("width").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
let height = rect.get("height").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
match position {
Some(p) => (x + p.x, y + p.y),
None => (x + width / 2.0, y + height / 2.0),
}
}
pub(crate) fn build_role_selector(role: &str, opts: &RoleOptions) -> String {
let mut sel = format!("internal:role={role}");
if let Some(name) = &opts.name {
let escaped = escape_for_attribute_selector(name, opts.exact == Some(true));
let _ = write!(sel, "[name={escaped}]");
}
if let Some(checked) = opts.checked {
let _ = write!(sel, "[checked={checked}]");
}
if let Some(disabled) = opts.disabled {
let _ = write!(sel, "[disabled={disabled}]");
}
if let Some(expanded) = opts.expanded {
let _ = write!(sel, "[expanded={expanded}]");
}
if let Some(level) = opts.level {
let _ = write!(sel, "[level={level}]");
}
if let Some(pressed) = opts.pressed {
let _ = write!(sel, "[pressed={pressed}]");
}
if let Some(selected) = opts.selected {
let _ = write!(sel, "[selected={selected}]");
}
if let Some(include_hidden) = opts.include_hidden {
let _ = write!(sel, "[include-hidden={include_hidden}]");
}
sel
}
pub(crate) fn build_text_like_selector(engine_prefix: &str, text: &StringOrRegex, opts: &TextOptions) -> String {
let body = escape_for_text_selector(text, opts.exact == Some(true));
format!("{engine_prefix}={body}")
}
pub(crate) fn build_attr_selector(attr: &str, value: &StringOrRegex, opts: &TextOptions) -> String {
let escaped = escape_for_attribute_selector(value, opts.exact == Some(true));
format!("internal:attr=[{attr}={escaped}]")
}
pub(crate) fn build_testid_selector(attr_name: &str, testid: &StringOrRegex) -> String {
let escaped = escape_for_attribute_selector(testid, true);
format!("internal:testid=[{attr_name}={escaped}]")
}
fn escape_for_text_selector(value: &StringOrRegex, exact: bool) -> String {
match value {
StringOrRegex::String(s) => {
let quoted = serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""));
format!("{quoted}{}", if exact { "s" } else { "i" })
},
StringOrRegex::Regex { source, flags } => escape_regex_for_selector(source, flags),
}
}
fn escape_for_attribute_selector(value: &StringOrRegex, exact: bool) -> String {
match value {
StringOrRegex::String(s) => {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"{}", if exact { "s" } else { "i" })
},
StringOrRegex::Regex { source, flags } => escape_regex_for_selector(source, flags),
}
}
fn escape_regex_for_selector(source: &str, flags: &str) -> String {
let has_unicode = flags.contains('u') || flags.contains('v');
if has_unicode {
format!("/{source}/{flags}")
} else {
let escaped_source = source.replace(">>", "\\>\\>");
format!("/{escaped_source}/{flags}")
}
}
fn json_quote(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("{s:?}"))
}
#[derive(serde::Deserialize, Default)]
struct AriaRaw {
#[serde(default)]
full: String,
#[serde(default, rename = "iframeRefs")]
iframe_refs: Vec<Option<String>>,
#[serde(default, rename = "iframeDepths")]
iframe_depths: std::collections::HashMap<String, i32>,
}
fn aria_opts_json(mode: &str, depth: Option<i32>, ref_prefix: &str) -> String {
let mut m = serde_json::Map::new();
m.insert("mode".into(), serde_json::Value::String(mode.to_string()));
if let Some(d) = depth {
m.insert("depth".into(), serde_json::Value::Number(d.into()));
}
if !ref_prefix.is_empty() {
m.insert("refPrefix".into(), serde_json::Value::String(ref_prefix.to_string()));
}
serde_json::Value::Object(m).to_string()
}
fn parse_aria_raw(s: &str) -> Result<AriaRaw> {
if s.trim().is_empty() {
return Ok(AriaRaw::default());
}
serde_json::from_str(s).map_err(|e| crate::error::FerriError::evaluation(format!("ariaSnapshot parse: {e}")))
}
fn aria_stitch_frame(
frame: crate::frame::Frame,
raw: AriaRaw,
mode: String,
depth: Option<i32>,
seq: Arc<std::sync::atomic::AtomicU32>,
) -> futures::future::BoxFuture<'static, Result<Vec<String>>> {
Box::pin(async move {
let rendered: Vec<String> = raw
.iframe_refs
.iter()
.flatten()
.filter(|r| raw.iframe_depths.contains_key(*r))
.cloned()
.collect();
let mut child_snaps: Vec<Vec<String>> = Vec::with_capacity(rendered.len());
for refv in &rendered {
child_snaps.push(aria_child_snapshot(&frame, refv, &mode, depth, &raw.iframe_depths, &seq).await?);
}
let re = regex::Regex::new(r"^(\s*)- iframe (?:\[active\] )?\[ref=([^\]]*)\]")
.map_err(|e| crate::error::FerriError::evaluation(format!("ariaSnapshot iframe regex: {e}")))?;
let mut out: Vec<String> = Vec::new();
for line in raw.full.split('\n') {
let Some(caps) = re.captures(line) else {
out.push(line.to_string());
continue;
};
let leading = caps.get(1).map_or("", |m| m.as_str());
let refv = caps.get(2).map_or("", |m| m.as_str());
let child = rendered.iter().position(|x| x == refv).map(|i| &child_snaps[i]);
let has = child.is_some_and(|c| !c.is_empty());
out.push(if has { format!("{line}:") } else { line.to_string() });
if let Some(child_lines) = child {
for l in child_lines {
out.push(format!("{leading} {l}"));
}
}
}
Ok(out)
})
}
async fn aria_child_snapshot(
frame: &crate::frame::Frame,
refv: &str,
mode: &str,
depth: Option<i32>,
depths: &std::collections::HashMap<String, i32>,
seq: &Arc<std::sync::atomic::AtomicU32>,
) -> Result<Vec<String>> {
const ARIA_FRAME_ATTR: &str = "data-fd-aria-ref";
let ref_json = serde_json::to_string(refv).unwrap_or_else(|_| format!("{refv:?}"));
let mark_js = format!("() => window.__fd.markIframeByAriaRef({ref_json}, \"{ARIA_FRAME_ATTR}\")");
let marked = matches!(
frame
.evaluate(&mark_js, crate::protocol::SerializedArgument::default(), Some(true))
.await?,
crate::protocol::SerializedValue::Bool(true)
);
if !marked {
return Ok(Vec::new());
}
let page = frame.page_arc();
let frame_id: Option<&str> = if frame.is_main_frame() { None } else { Some(frame.id()) };
let sel = format!("[{ARIA_FRAME_ATTR}=\"{refv}\"]");
let Ok(iframe_node) = selectors::query_one(page.inner(), &sel, false, frame_id).await else {
return Ok(Vec::new());
};
let iframe_handle = crate::element_handle::ElementHandle::from_any_element(Arc::clone(page), iframe_node).await?;
let Some(child_frame) = iframe_handle.content_frame().await? else {
return Ok(Vec::new());
};
let iframe_depth = depths.get(refv).copied().unwrap_or(0);
let child_depth = match depth {
Some(d) if d != 0 => Some(d - iframe_depth - 1),
_ => None,
};
let n = seq.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
let prefix = format!("f{n}");
let copts = aria_opts_json(mode, child_depth, &prefix);
let body_js = format!("() => JSON.stringify(window.__fd.incrementalAriaSnapshot(document.body, {copts}))");
let raw_s = child_frame
.evaluate(&body_js, crate::protocol::SerializedArgument::default(), Some(true))
.await?
.as_str()
.map(std::string::ToString::to_string)
.unwrap_or_default();
if raw_s.is_empty() {
return Ok(Vec::new());
}
let child_raw = parse_aria_raw(&raw_s)?;
aria_stitch_frame(child_frame, child_raw, mode.to_string(), child_depth, Arc::clone(seq)).await
}