use crate::backend::{AnyElement, AnyPage};
use crate::error::{FerriError, Result};
use crate::selectors;
use rustc_hash::FxHashMap;
async fn ensure_mcp_support(page: &AnyPage) -> Result<String> {
let fd = page.injected_script().await?;
page.evaluate(selectors::MCP_SUPPORT_JS).await?;
Ok(fd)
}
pub struct SearchOptions {
pub pattern: String,
pub regex: bool,
pub case_sensitive: bool,
pub context_chars: usize,
pub css_scope: Option<String>,
pub max_results: usize,
}
impl Default for SearchOptions {
fn default() -> Self {
Self {
pattern: String::new(),
regex: false,
case_sensitive: false,
context_chars: 150,
css_scope: None,
max_results: 25,
}
}
}
#[derive(Debug, Clone)]
pub struct SearchMatch {
pub match_text: String,
pub context: String,
pub element_path: String,
pub char_position: usize,
}
#[derive(Debug)]
pub struct SearchResult {
pub matches: Vec<SearchMatch>,
pub total: usize,
pub has_more: bool,
}
pub struct FindElementsOptions {
pub selector: String,
pub attributes: Option<Vec<String>>,
pub max_results: usize,
pub include_text: bool,
}
impl Default for FindElementsOptions {
fn default() -> Self {
Self {
selector: String::new(),
attributes: None,
max_results: 50,
include_text: true,
}
}
}
#[derive(Debug, Clone)]
pub struct FoundElement {
pub index: usize,
pub tag: String,
pub text: Option<String>,
pub attrs: FxHashMap<String, String>,
pub children_count: usize,
}
#[derive(Debug)]
pub struct FindResult {
pub elements: Vec<FoundElement>,
pub total: usize,
}
#[derive(Debug, Clone)]
pub struct SelectResult {
pub selected_text: String,
pub selected_value: String,
}
#[derive(Debug, Clone)]
pub struct DropdownOption {
pub index: usize,
pub text: String,
pub value: String,
pub selected: bool,
}
#[derive(Debug)]
pub enum ClickGuardError {
IsSelect,
IsFileInput,
}
impl std::fmt::Display for ClickGuardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::IsSelect => write!(
f,
"Cannot click <select> directly. Use select_option or get_dropdown_options instead."
),
Self::IsFileInput => write!(
f,
"Cannot click file input directly. Use evaluate() to set files programmatically."
),
}
}
}
#[derive(Debug, Clone)]
pub struct ScrollInfo {
pub scroll_y: i64,
pub scroll_height: i64,
pub viewport_height: i64,
}
async fn rt_eval(page: &AnyPage, js: &str) -> Result<Option<serde_json::Value>> {
page.evaluate(js).await
}
async fn rt_eval_str(page: &AnyPage, js: &str) -> Result<String> {
let val = rt_eval(page, js).await?;
Ok(
val
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_default(),
)
}
pub async fn resolve_element<S: std::hash::BuildHasher>(
page: &AnyPage,
ref_map: &std::collections::HashMap<String, i64, S>,
r#ref: Option<&str>,
selector: Option<&str>,
) -> Result<AnyElement> {
if let Some(r) = r#ref {
let backend_id = ref_map
.get(r)
.ok_or_else(|| FerriError::invalid_argument("ref", format!("unknown ref '{r}'. Take a new snapshot.")))?;
return page.resolve_backend_node(*backend_id, r).await;
}
let sel = selector
.ok_or_else(|| FerriError::invalid_argument("ref-or-selector", "provide 'ref' (from snapshot) or 'selector'"))?;
selectors::query_one(page, sel, false, None).await
}
pub async fn suggest_selectors(page: &AnyPage) -> Vec<String> {
let Ok(fd) = ensure_mcp_support(page).await else {
return Vec::new();
};
let json_str = rt_eval_str(page, &format!("{fd}.suggestSelectors()"))
.await
.unwrap_or_default();
if let Ok(data) = serde_json::from_str::<serde_json::Value>(&json_str) {
let mut suggestions = Vec::new();
if let Some(ids) = data["ids"].as_array() {
for id in ids.iter().filter_map(|v| v.as_str()) {
suggestions.push(id.to_string());
}
}
if let Some(inputs) = data["inputs"].as_array() {
for input in inputs.iter().filter_map(|v| v.as_str()) {
suggestions.push(input.to_string());
}
}
suggestions
} else {
Vec::new()
}
}
pub async fn check_click_guard(element: &AnyElement, page: &AnyPage) -> std::result::Result<(), ClickGuardError> {
let _ = page.ensure_engine_injected().await;
let fd = "window.__fd";
let guard = element
.call_js_fn_value(&format!("function() {{ return {fd} ? {fd}.clickGuard(this) : ''; }}"))
.await
.ok()
.flatten()
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_default();
match guard.as_str() {
"select" => Err(ClickGuardError::IsSelect),
"file" => Err(ClickGuardError::IsFileInput),
_ => Ok(()),
}
}
#[derive(Debug)]
pub enum ClickPrep {
Ready { x: f64, y: f64 },
IsSelect,
IsFileInput,
NotActionable { reason: String },
}
pub async fn click_prep(
element: &AnyElement,
page: &AnyPage,
position: Option<crate::options::Point>,
scroll_align: Option<&str>,
) -> Result<ClickPrep> {
let _ = page.ensure_engine_injected().await;
let position_lit = match position {
Some(p) => format!("{{x:{},y:{}}}", p.x, p.y),
None => "null".to_string(),
};
let scroll_lit = scroll_align.unwrap_or("null");
let js = format!(
"async function() {{ return JSON.stringify(await window.__fd.clickPrep(this, {position_lit}, ['visible','enabled','stable'], {scroll_lit})); }}"
);
let raw = element
.call_js_fn_value(&js)
.await?
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_default();
if raw.is_empty() {
return Err(FerriError::backend("click_prep: empty response from page-side helper"));
}
let parsed: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| FerriError::Backend(format!("click_prep: parse: {e}")))?;
let guard = parsed.get("guard").and_then(|v| v.as_str()).unwrap_or("");
match guard {
"select" => return Ok(ClickPrep::IsSelect),
"file" => return Ok(ClickPrep::IsFileInput),
_ => {},
}
let actionable = parsed
.get("actionable")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !actionable {
let reason = parsed
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("notconnected")
.to_string();
return Ok(ClickPrep::NotActionable {
reason: format!("error:not{reason}"),
});
}
let point = parsed
.get("point")
.ok_or_else(|| FerriError::evaluation("click_prep: missing point"))?;
let x = point
.get("x")
.and_then(serde_json::Value::as_f64)
.ok_or_else(|| FerriError::evaluation("click_prep: bad x"))?;
let y = point
.get("y")
.and_then(serde_json::Value::as_f64)
.ok_or_else(|| FerriError::evaluation("click_prep: bad y"))?;
Ok(ClickPrep::Ready { x, y })
}
pub async fn fill(element: &AnyElement, page: &AnyPage, value: &str, force: bool) -> Result<()> {
if !force {
let fd = page.injected_script().await?;
let state_raw = element
.call_js_fn_value(&format!(
"function() {{ return {fd}.checkElementStates(this, ['visible', 'enabled', 'editable']); }}"
))
.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(FerriError::backend(state_raw));
}
}
let escaped = value.replace('\\', "\\\\").replace('\'', "\\'");
element
.call_js_fn(&format!(
"function() {{ \
this.focus(); \
if (this.isContentEditable) {{ \
this.textContent = ''; \
this.textContent = '{escaped}'; \
this.dispatchEvent(new InputEvent('input', {{bubbles: true}})); \
}} else {{ \
var proto = Object.getPrototypeOf(this); \
var desc = Object.getOwnPropertyDescriptor(proto, 'value'); \
var setter = desc && desc.set; \
if (setter) {{ \
setter.call(this, ''); \
setter.call(this, '{escaped}'); \
}} else {{ \
this.value = ''; \
this.value = '{escaped}'; \
}} \
this.dispatchEvent(new Event('input', {{bubbles: true}})); \
this.dispatchEvent(new Event('change', {{bubbles: true}})); \
}} \
}}"
))
.await
.map_err(|e| FerriError::Backend(format!("Fill: {e}")))
}
pub async fn navigate_with_health_check(page: &AnyPage, url: &str) -> Result<()> {
page.goto(url, crate::backend::NavLifecycle::Load, 30_000, None).await?;
let url_lower = url.to_lowercase();
if url_lower.starts_with("http://") || url_lower.starts_with("https://") {
let check_js = "document.body ? document.body.children.length : 0";
let is_empty = || async {
page
.evaluate(check_js)
.await
.ok()
.flatten()
.and_then(|v| v.as_i64())
.unwrap_or(0)
== 0
};
if is_empty().await {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if is_empty().await {
let _ = page.reload(crate::backend::NavLifecycle::Load, 30_000).await;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
if is_empty().await {
return Err(
"Page loaded but DOM is empty. The page may need JS rendering, \
have anti-bot protection, or the URL may be wrong. \
Try wait_for with a selector, or try a different URL."
.into(),
);
}
}
}
}
Ok(())
}
pub async fn search_page(page: &AnyPage, opts: &SearchOptions) -> Result<SearchResult> {
let pattern = serde_json::to_string(&opts.pattern)?;
let is_regex = if opts.regex { "true" } else { "false" };
let case_sensitive = if opts.case_sensitive { "true" } else { "false" };
let context_chars = opts.context_chars;
let css_scope = serde_json::to_string(&opts.css_scope)?;
let max_results = opts.max_results;
let fd = ensure_mcp_support(page).await?;
let js =
format!("{fd}.searchPage({pattern}, {is_regex}, {case_sensitive}, {context_chars}, {css_scope}, {max_results})");
let result_str = rt_eval_str(page, &js).await?;
let data: serde_json::Value = serde_json::from_str(&result_str).unwrap_or(serde_json::json!({}));
if let Some(err) = data["error"].as_str() {
return Err(FerriError::Backend(err.to_string()));
}
let total = usize::try_from(data["total"].as_u64().unwrap_or(0)).unwrap_or(0);
let has_more = data["has_more"].as_bool().unwrap_or(false);
let matches = data["matches"]
.as_array()
.map(|arr| {
arr
.iter()
.map(|m| SearchMatch {
match_text: m["match_text"].as_str().unwrap_or("").to_string(),
context: m["context"].as_str().unwrap_or("").to_string(),
element_path: m["element_path"].as_str().unwrap_or("").to_string(),
char_position: usize::try_from(m["char_position"].as_u64().unwrap_or(0)).unwrap_or(0),
})
.collect()
})
.unwrap_or_default();
Ok(SearchResult {
matches,
total,
has_more,
})
}
#[must_use]
pub fn format_search_results(result: &SearchResult, pattern: &str) -> String {
if result.total == 0 {
return format!("No matches found for \"{pattern}\" on page.");
}
let mut lines = vec![format!(
"Found {} match{} for \"{pattern}\" on page:\n",
result.total,
if result.total == 1 { "" } else { "es" }
)];
for (i, m) in result.matches.iter().enumerate() {
let loc = if m.element_path.is_empty() {
String::new()
} else {
format!(" (in {})", m.element_path)
};
lines.push(format!("[{}] {}{loc}", i + 1, m.context));
}
if result.has_more {
lines.push(format!(
"\n... showing {} of {} total. Increase max_results to see more.",
result.matches.len(),
result.total
));
}
lines.join("\n")
}
pub async fn find_elements(page: &AnyPage, opts: &FindElementsOptions) -> Result<FindResult> {
if selectors::is_rich_selector(&opts.selector) {
let matched = selectors::query_all(page, &opts.selector, None).await?;
selectors::cleanup_tags(page).await;
let total = matched.len();
let elements = matched
.into_iter()
.take(opts.max_results)
.map(|m| FoundElement {
index: m.index,
tag: m.tag,
text: if opts.include_text { Some(m.text) } else { None },
attrs: FxHashMap::default(),
children_count: 0,
})
.collect();
return Ok(FindResult { elements, total });
}
let selector = serde_json::to_string(&opts.selector)?;
let attributes = serde_json::to_string(&opts.attributes)?;
let max_results = opts.max_results;
let include_text = if opts.include_text { "true" } else { "false" };
let fd = ensure_mcp_support(page).await?;
let js = format!("{fd}.findElementsCSS({selector}, {attributes}, {max_results}, {include_text})");
let result_str = rt_eval_str(page, &js).await?;
let data: serde_json::Value = serde_json::from_str(&result_str).unwrap_or(serde_json::json!({}));
if let Some(err) = data["error"].as_str() {
return Err(FerriError::Backend(err.to_string()));
}
let total = usize::try_from(data["total"].as_u64().unwrap_or(0)).unwrap_or(0);
let elements = data["elements"]
.as_array()
.map(|arr| {
arr
.iter()
.map(|el| {
let mut attrs = FxHashMap::default();
if let Some(obj) = el["attrs"].as_object() {
for (k, v) in obj {
attrs.insert(k.clone(), v.as_str().unwrap_or("").to_string());
}
}
FoundElement {
index: usize::try_from(el["index"].as_u64().unwrap_or(0)).unwrap_or(0),
tag: el["tag"].as_str().unwrap_or("?").to_string(),
text: el["text"].as_str().map(std::string::ToString::to_string),
attrs,
children_count: usize::try_from(el["children_count"].as_u64().unwrap_or(0)).unwrap_or(0),
}
})
.collect()
})
.unwrap_or_default();
Ok(FindResult { elements, total })
}
#[must_use]
pub fn format_find_results(result: &FindResult, selector: &str) -> String {
if result.total == 0 {
return format!("No elements found matching \"{selector}\".");
}
let mut lines = vec![format!(
"Found {} element{} matching \"{selector}\":\n",
result.total,
if result.total == 1 { "" } else { "s" }
)];
for el in &result.elements {
let mut parts = vec![format!("[{}] <{}>", el.index, el.tag)];
if let Some(text) = &el.text {
if !text.is_empty() {
let display: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
let display = if display.len() > 120 {
format!("{}...", &display[..120])
} else {
display
};
parts.push(format!("\"{display}\""));
}
}
if !el.attrs.is_empty() {
let attr_strs: Vec<String> = el.attrs.iter().map(|(k, v)| format!("{k}=\"{v}\"")).collect();
parts.push(format!("{{{}}}", attr_strs.join(", ")));
}
parts.push(format!("({} children)", el.children_count));
lines.push(parts.join(" "));
}
if result.elements.len() < result.total {
lines.push(format!(
"\nShowing {} of {} total. Increase max_results to see more.",
result.elements.len(),
result.total
));
}
lines.join("\n")
}
pub async fn select_option(element: &AnyElement, page: &AnyPage, target: &str) -> Result<SelectResult> {
let escaped = target.replace('\\', "\\\\").replace('\'', "\\'");
let fd = ensure_mcp_support(page).await?;
let result_json = element
.call_js_fn_value(&format!(
"function() {{ return JSON.stringify({fd}.selectOption(this, '{escaped}')); }}"
))
.await
.ok()
.flatten()
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_else(|| "{}".into());
let result: serde_json::Value = serde_json::from_str(&result_json).unwrap_or(serde_json::json!({}));
if let Some(err) = result["error"].as_str() {
let mut msg = format!("{err}.");
if let Some(avail) = result["available"].as_array() {
let opts: Vec<&str> = avail.iter().filter_map(|v| v.as_str()).collect();
let _ = std::fmt::Write::write_fmt(&mut msg, format_args!(" Available options: {}", opts.join(", ")));
}
return Err(FerriError::Backend(msg));
}
Ok(SelectResult {
selected_text: result["selected"].as_str().unwrap_or(target).to_string(),
selected_value: result["value"].as_str().unwrap_or("").to_string(),
})
}
pub async fn get_dropdown_options(element: &AnyElement, page: &AnyPage) -> Result<Vec<DropdownOption>> {
let fd = page.injected_script().await?;
let result_json = element
.call_js_fn_value(&format!(
"function() {{ return JSON.stringify({fd}.getOptions(this)); }}"
))
.await
.ok()
.flatten()
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_else(|| "{}".into());
let result: serde_json::Value = serde_json::from_str(&result_json).unwrap_or(serde_json::json!({}));
if let Some(err) = result["error"].as_str() {
return Err(FerriError::Backend(err.to_string()));
}
let opts = result["options"]
.as_array()
.map(|arr| {
arr
.iter()
.map(|o| DropdownOption {
index: usize::try_from(o["index"].as_u64().unwrap_or(0)).unwrap_or(0),
text: o["text"].as_str().unwrap_or("").to_string(),
value: o["value"].as_str().unwrap_or("").to_string(),
selected: o["selected"].as_bool().unwrap_or(false),
})
.collect()
})
.unwrap_or_default();
Ok(opts)
}
pub async fn scroll_info(page: &AnyPage) -> Result<ScrollInfo> {
let fd = page.injected_script().await?;
let result = rt_eval_str(page, &format!("{fd}.scrollInfo()")).await?;
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap_or(serde_json::json!({}));
Ok(ScrollInfo {
scroll_y: parsed["scrollY"].as_i64().unwrap_or(0),
scroll_height: parsed["scrollHeight"].as_i64().unwrap_or(0),
viewport_height: parsed["viewportHeight"].as_i64().unwrap_or(0),
})
}
pub async fn console_error_count(page: &AnyPage) -> i64 {
let Ok(fd) = ensure_mcp_support(page).await else {
return 0;
};
rt_eval(page, &format!("{fd}.consoleErrors()"))
.await
.ok()
.flatten()
.and_then(|v| v.as_i64())
.unwrap_or(0)
}
pub async fn extract_markdown(page: &AnyPage) -> Result<String> {
let fd = ensure_mcp_support(page).await?;
rt_eval_str(page, &format!("{fd}.extractMarkdown()")).await
}
pub async fn upload_file(page: &AnyPage, selector: &str, paths: &[String]) -> Result<()> {
page.set_file_input(selector, paths).await
}
pub async fn resolve_click_point(element: &AnyElement, position: Option<crate::options::Point>) -> Result<(f64, f64)> {
let position_js = match position {
Some(p) => format!("{{x:{},y:{}}}", p.x, p.y),
None => "null".to_string(),
};
let js = format!(
"function() {{ \
if (typeof this.scrollIntoViewIfNeeded === 'function') {{ \
this.scrollIntoViewIfNeeded(); \
}} else {{ \
this.scrollIntoView({{ block: 'center', inline: 'center' }}); \
}} \
var r = this.getBoundingClientRect(); \
var pos = {position_js}; \
var x = pos ? (r.x + pos.x) : (r.x + r.width / 2); \
var y = pos ? (r.y + pos.y) : (r.y + r.height / 2); \
var win = this.ownerDocument.defaultView; \
while (win && win !== win.parent && win.frameElement) {{ \
var fr = win.frameElement.getBoundingClientRect(); \
x += fr.x; \
y += fr.y; \
win = win.parent; \
}} \
return {{ x: x, y: y }}; \
}}"
);
let val = element.call_js_fn_value(&js).await?;
val
.and_then(|v| {
let x = v.get("x").and_then(serde_json::Value::as_f64)?;
let y = v.get("y").and_then(serde_json::Value::as_f64)?;
Some((x, y))
})
.ok_or_else(|| FerriError::backend("could not compute click point"))
}
pub async fn click_with_opts(element: &AnyElement, page: &AnyPage, opts: &crate::options::ClickOptions) -> Result<()> {
let args = crate::backend::BackendClickArgs::from_options(opts);
let retry_backoff_ms: [u64; 5] = [0, 20, 100, 100, 500];
let mut attempt: usize = 0;
loop {
if attempt > 0 {
let wait_ms = retry_backoff_ms[attempt.min(retry_backoff_ms.len() - 1)];
if wait_ms > 0 {
tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
}
}
let scroll_align = match attempt % 4 {
0 => None,
1 => Some("{block:'end',inline:'end'}"),
2 => Some("{block:'center',inline:'center'}"),
_ => Some("{block:'start',inline:'start'}"),
};
let (x, y) = if opts.is_force() {
resolve_click_point(element, opts.position).await?
} else {
match click_prep(element, page, opts.position, scroll_align).await? {
ClickPrep::Ready { x, y } => (x, y),
ClickPrep::IsSelect => return Err(FerriError::Backend(ClickGuardError::IsSelect.to_string())),
ClickPrep::IsFileInput => return Err(FerriError::Backend(ClickGuardError::IsFileInput.to_string())),
ClickPrep::NotActionable { reason } => {
if reason.contains("notconnected") {
return Err(FerriError::Backend(reason));
}
attempt += 1;
if attempt >= retry_backoff_ms.len() + 2 {
return Err(FerriError::Backend(reason));
}
continue;
},
}
};
let interceptor_installed = if opts.is_force() || opts.is_trial() {
false
} else {
matches!(install_hit_interceptor(element, x, y).await, Ok(true))
};
page.press_modifiers(&opts.modifiers).await?;
let dispatch = if opts.is_trial() {
Ok(())
} else {
page.click_at_with(x, y, &args).await
};
let _ = page.release_modifiers(&opts.modifiers).await;
if let Err(e) = dispatch {
if interceptor_installed {
let _ = finalize_hit_interceptor(element).await;
}
return Err(e);
}
if !interceptor_installed {
return Ok(());
}
let Ok(hit) = finalize_hit_interceptor(element).await else {
return Ok(());
};
match hit {
HitResult::Done => return Ok(()),
HitResult::Missed { description } => {
attempt += 1;
if attempt >= retry_backoff_ms.len() + 2 {
return Err(FerriError::Backend(format!(
"{description} intercepts pointer events after {attempt} attempts"
)));
}
},
}
}
}
enum HitResult {
Done,
Missed { description: String },
}
async fn install_hit_interceptor(element: &AnyElement, x: f64, y: f64) -> Result<bool> {
let js = format!("function() {{ return window.__fd.installHitInterceptor(this, {{x: {x}, y: {y}}}, 'mouse'); }}");
let val = element.call_js_fn_value(&js).await?;
let s = val.and_then(|v| v.as_str().map(std::string::ToString::to_string));
Ok(matches!(s.as_deref(), Some("ok")))
}
async fn finalize_hit_interceptor(element: &AnyElement) -> Result<HitResult> {
let js = "function() { return JSON.stringify(window.__fd.finalizeHitInterceptor()); }";
let val = element.call_js_fn_value(js).await?;
let raw = val
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_default();
if raw.is_empty() || raw == "\"done\"" {
return Ok(HitResult::Done);
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&raw) {
if let Some(desc) = parsed.get("hitTargetDescription").and_then(|v| v.as_str()) {
return Ok(HitResult::Missed {
description: desc.to_string(),
});
}
}
Ok(HitResult::Done)
}
pub async fn hover_with_opts(element: &AnyElement, page: &AnyPage, opts: &crate::options::HoverOptions) -> Result<()> {
if !opts.is_force() {
wait_for_states(element, page, &["visible", "stable"]).await.ok();
}
page.press_modifiers(&opts.modifiers).await?;
let result: Result<()> = if opts.is_trial() {
Ok(())
} else {
match resolve_click_point(element, opts.position).await {
Ok((x, y)) => {
let args = crate::backend::BackendHoverArgs {
modifiers_bitmask: crate::options::modifiers_bitmask(&opts.modifiers),
steps: 1,
};
page.hover_at_with(x, y, &args).await
},
Err(e) => Err(e),
}
};
let _ = page.release_modifiers(&opts.modifiers).await;
result
}
pub async fn tap_with_opts(element: &AnyElement, page: &AnyPage, opts: &crate::options::TapOptions) -> Result<()> {
if !opts.is_force() {
wait_for_states(element, page, &["visible", "enabled", "stable"])
.await
.ok();
}
page.press_modifiers(&opts.modifiers).await?;
let result: Result<()> = if opts.is_trial() {
Ok(())
} else {
match resolve_click_point(element, opts.position).await {
Ok((x, y)) => {
let args = crate::backend::BackendTapArgs {
modifiers_bitmask: crate::options::modifiers_bitmask(&opts.modifiers),
};
page.tap_at_with(x, y, &args).await
},
Err(e) => Err(e),
}
};
let _ = page.release_modifiers(&opts.modifiers).await;
result
}
pub async fn select_options(
element: &AnyElement,
page: &AnyPage,
values: &[crate::options::SelectOptionValue],
) -> Result<Vec<String>> {
let fd = page.injected_script().await?;
let values_json =
serde_json::to_string(values).map_err(|e| FerriError::Backend(format!("select_option serialize: {e}")))?;
let js = format!(
"function() {{ \
var descriptors = {values_json}; \
var result = {fd}.selectOptions.apply(null, [this].concat(descriptors)); \
if (typeof result === 'string') return JSON.stringify({{ error: result }}); \
return JSON.stringify({{ selected: result }}); \
}}"
);
let raw = element
.call_js_fn_value(&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!({}));
if let Some(err) = parsed.get("error").and_then(|v| v.as_str()) {
return Err(FerriError::Backend(err.to_string()));
}
Ok(
parsed
.get("selected")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default(),
)
}
pub async fn wait_for_actionable(element: &AnyElement, page: &AnyPage) -> Result<()> {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
let _ = page.ensure_engine_injected().await;
let fd = "window.__fd";
loop {
if tokio::time::Instant::now() >= deadline {
return Err(FerriError::timeout("element not actionable", 5_000));
}
let val = element
.call_js_fn_value(&format!(
"function() {{ \
return JSON.stringify({fd}.isActionable(this)); \
}}"
))
.await
.ok()
.flatten()
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_default();
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&val) {
if result["actionable"].as_bool() == Some(true) {
return Ok(());
}
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
pub async fn wait_for_states(element: &AnyElement, page: &AnyPage, states: &[&str]) -> Result<()> {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
let _ = page.ensure_engine_injected().await;
let states_lit = states.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(",");
let js = format!(
"async function() {{ return JSON.stringify((await window.__fd.awaitStates(this, [{states_lit}])) ?? 'ok'); }}"
);
loop {
if tokio::time::Instant::now() >= deadline {
return Err(FerriError::timeout("element not actionable", 5_000));
}
let val = element
.call_js_fn_value(&js)
.await
.ok()
.flatten()
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_default();
if val == "\"ok\"" {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}