#![allow(missing_docs)]
use std::collections::HashMap;
use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
#[derive(Debug, Clone)]
pub struct RefEntry {
pub backend_node_id: Option<i64>,
pub role: String,
pub name: String,
pub nth: Option<usize>,
pub selector: Option<String>,
pub frame_id: Option<String>,
}
pub struct RefMap {
map: HashMap<String, RefEntry>,
next_ref: usize,
}
impl Default for RefMap {
fn default() -> Self {
Self::new()
}
}
impl RefMap {
pub fn new() -> Self {
Self {
map: HashMap::new(),
next_ref: 1,
}
}
pub fn add(
&mut self,
ref_id: String,
backend_node_id: Option<i64>,
role: &str,
name: &str,
nth: Option<usize>,
) {
self.add_with_frame(ref_id, backend_node_id, role, name, nth, None);
}
pub fn add_with_frame(
&mut self,
ref_id: String,
backend_node_id: Option<i64>,
role: &str,
name: &str,
nth: Option<usize>,
frame_id: Option<&str>,
) {
self.map.insert(
ref_id,
RefEntry {
backend_node_id,
role: role.to_string(),
name: name.to_string(),
nth,
selector: None,
frame_id: frame_id.map(|s| s.to_string()),
},
);
}
pub fn add_selector(
&mut self,
ref_id: String,
selector: String,
role: &str,
name: &str,
nth: Option<usize>,
) {
self.map.insert(
ref_id,
RefEntry {
backend_node_id: None,
role: role.to_string(),
name: name.to_string(),
nth,
selector: Some(selector),
frame_id: None,
},
);
}
pub fn get(&self, ref_id: &str) -> Option<&RefEntry> {
self.map.get(ref_id)
}
pub fn entries_sorted(&self) -> Vec<(String, RefEntry)> {
let mut entries = self
.map
.iter()
.map(|(ref_id, entry)| (ref_id.clone(), entry.clone()))
.collect::<Vec<_>>();
entries.sort_by_key(|(ref_id, _)| {
ref_id
.strip_prefix('e')
.and_then(|n| n.parse::<usize>().ok())
.unwrap_or(usize::MAX)
});
entries
}
pub fn remove(&mut self, ref_id: &str) {
self.map.remove(ref_id);
}
pub fn clear(&mut self) {
self.map.clear();
self.next_ref = 1;
}
pub fn next_ref_num(&self) -> usize {
self.next_ref
}
pub fn set_next_ref_num(&mut self, n: usize) {
self.next_ref = n;
}
}
pub fn parse_ref(input: &str) -> Option<String> {
let trimmed = input.trim();
if let Some(stripped) = trimmed.strip_prefix('@') {
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
return Some(stripped.to_string());
}
}
if let Some(stripped) = trimmed.strip_prefix("ref=") {
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
return Some(stripped.to_string());
}
}
if trimmed.starts_with('e')
&& trimmed.len() > 1
&& trimmed[1..].chars().all(|c| c.is_ascii_digit())
{
return Some(trimmed.to_string());
}
None
}
static ACTIVE_FRAME: std::sync::OnceLock<std::sync::Mutex<Option<String>>> =
std::sync::OnceLock::new();
pub fn set_active_frame(frame_id: Option<&str>) {
let mutex = ACTIVE_FRAME.get_or_init(|| std::sync::Mutex::new(None));
match mutex.lock() {
Ok(mut guard) => *guard = frame_id.map(String::from),
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = frame_id.map(String::from);
}
}
}
fn active_frame() -> Option<String> {
ACTIVE_FRAME.get().and_then(|m| match m.lock() {
Ok(g) => g.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
})
}
pub(super) async fn frame_owner_object_id(
client: &CdpClient,
session_id: &str,
frame_id: &str,
) -> Result<String, String> {
let owner = client
.send_command(
"DOM.getFrameOwner",
Some(serde_json::json!({ "frameId": frame_id })),
Some(session_id),
)
.await?;
let backend_node_id = owner
.get("backendNodeId")
.and_then(|v| v.as_i64())
.ok_or_else(|| format!("Could not resolve the owner element of frame {}", frame_id))?;
let result: DomResolveNodeResult = client
.send_command_typed(
"DOM.resolveNode",
&DomResolveNodeParams {
backend_node_id: Some(backend_node_id),
node_id: None,
object_group: Some("browser-automation-cli".to_string()),
},
Some(session_id),
)
.await?;
result
.object
.object_id
.ok_or_else(|| format!("No objectId for the owner element of frame {}", frame_id))
}
async fn resolve_center_in_same_process_frame(
client: &CdpClient,
session_id: &str,
frame_id: &str,
selector: &str,
) -> Result<(f64, f64), String> {
let owner_object_id = frame_owner_object_id(client, session_id, frame_id).await?;
let find_expr = build_find_element_js_in("doc", selector);
let function = format!(
r#"function() {{
const doc = this.contentDocument;
if (!doc) return null;
const el = {find_expr};
if (!el) return null;
if (el.scrollIntoViewIfNeeded) el.scrollIntoViewIfNeeded(true);
else el.scrollIntoView({{ block: 'center', inline: 'center' }});
const rect = el.getBoundingClientRect();
let x = rect.x + rect.width / 2;
let y = rect.y + rect.height / 2;
let win = doc.defaultView;
while (win && win.frameElement) {{
const frameRect = win.frameElement.getBoundingClientRect();
x += frameRect.x + win.frameElement.clientLeft;
y += frameRect.y + win.frameElement.clientTop;
win = win.parent;
}}
const blockerAt = {BLOCKER_AT_JS};
const topDoc = win ? win.document : doc;
return {{ x: x, y: y, blocker: blockerAt(topDoc, el, x, y) }};
}}"#,
);
let result = client
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": owner_object_id,
"functionDeclaration": function,
"returnByValue": true,
})),
Some(session_id),
)
.await?;
let value = result.get("result").and_then(|r| r.get("value"));
if let Some(blocker) = value
.and_then(|v| v.get("blocker"))
.and_then(|v| v.as_str())
{
return Err(intercepted_error(selector, blocker));
}
let x = value.and_then(|v| v.get("x")).and_then(|v| v.as_f64());
let y = value.and_then(|v| v.get("y")).and_then(|v| v.as_f64());
match (x, y) {
(Some(x), Some(y)) => Ok((x, y)),
_ => Err(format!(
"Element not found in the selected frame: {}",
selector
)),
}
}
async fn resolve_object_in_same_process_frame(
client: &CdpClient,
session_id: &str,
frame_id: &str,
selector: &str,
) -> Result<String, String> {
let owner_object_id = frame_owner_object_id(client, session_id, frame_id).await?;
let find_expr = build_find_element_js_in("doc", selector);
let function = format!(
"function() {{ const doc = this.contentDocument; if (!doc) return null; return {find_expr}; }}",
);
let result = client
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": owner_object_id,
"functionDeclaration": function,
"returnByValue": false,
})),
Some(session_id),
)
.await?;
result
.get("result")
.and_then(|r| r.get("objectId"))
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| format!("Element not found in the selected frame: {}", selector))
}
pub async fn resolve_element_center(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(f64, f64, String), String> {
if let Some(ref_id) = parse_ref(selector_or_ref) {
let entry = ref_map
.get(&ref_id)
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
let effective_session_id =
resolve_frame_session(entry.frame_id.as_deref(), session_id, iframe_sessions);
if let Some(backend_node_id) = entry.backend_node_id {
scroll_node_into_view(client, effective_session_id, backend_node_id).await;
let result: Result<DomGetBoxModelResult, String> = client
.send_command_typed(
"DOM.getBoxModel",
&DomGetBoxModelParams {
backend_node_id: Some(backend_node_id),
node_id: None,
object_id: None,
},
Some(effective_session_id),
)
.await;
if let Ok(r) = result {
let (x, y) = box_model_center(&r.model);
check_node_interception(
client,
effective_session_id,
backend_node_id,
selector_or_ref,
x,
y,
)
.await?;
return Ok((x, y, effective_session_id.to_string()));
}
}
let fresh_id = find_node_id_by_role_name(
client,
session_id,
&entry.role,
&entry.name,
entry.nth,
entry.frame_id.as_deref(),
iframe_sessions,
)
.await?;
scroll_node_into_view(client, effective_session_id, fresh_id).await;
let result: DomGetBoxModelResult = client
.send_command_typed(
"DOM.getBoxModel",
&DomGetBoxModelParams {
backend_node_id: Some(fresh_id),
node_id: None,
object_id: None,
},
Some(effective_session_id),
)
.await?;
let (x, y) = box_model_center(&result.model);
check_node_interception(
client,
effective_session_id,
fresh_id,
selector_or_ref,
x,
y,
)
.await?;
return Ok((x, y, effective_session_id.to_string()));
}
if let Some(frame_id) = active_frame() {
if let Some(frame_session) = iframe_sessions.get(&frame_id) {
let (x, y) = resolve_by_selector(client, frame_session, selector_or_ref).await?;
return Ok((x, y, frame_session.clone()));
}
let (x, y) =
resolve_center_in_same_process_frame(client, session_id, &frame_id, selector_or_ref)
.await?;
return Ok((x, y, session_id.to_string()));
}
let (x, y) = resolve_by_selector(client, session_id, selector_or_ref).await?;
Ok((x, y, session_id.to_string()))
}
async fn check_node_interception(
client: &CdpClient,
session_id: &str,
backend_node_id: i64,
target: &str,
x: f64,
y: f64,
) -> Result<(), String> {
let resolved: Result<DomResolveNodeResult, String> = client
.send_command_typed(
"DOM.resolveNode",
&DomResolveNodeParams {
backend_node_id: Some(backend_node_id),
node_id: None,
object_group: Some("browser-automation-cli".to_string()),
},
Some(session_id),
)
.await;
let Ok(resolved) = resolved else {
return Ok(());
};
let Some(object_id) = resolved.object.object_id else {
return Ok(());
};
let function = format!(
r#"function(x, y) {{
let topDoc = this.ownerDocument || document;
while (topDoc.defaultView && topDoc.defaultView.frameElement) {{
topDoc = topDoc.defaultView.frameElement.ownerDocument;
}}
const blockerAt = {BLOCKER_AT_JS};
return blockerAt(topDoc, this, x, y);
}}"#,
);
let result = client
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": object_id,
"functionDeclaration": function,
"arguments": [{ "value": x }, { "value": y }],
"returnByValue": true,
})),
Some(session_id),
)
.await;
if let Ok(value) = result {
if let Some(blocker) = value
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
{
return Err(intercepted_error(target, blocker));
}
}
Ok(())
}
async fn scroll_node_into_view(client: &CdpClient, session_id: &str, backend_node_id: i64) {
let _ = client
.send_command(
"DOM.scrollIntoViewIfNeeded",
Some(serde_json::json!({ "backendNodeId": backend_node_id })),
Some(session_id),
)
.await;
}
pub async fn resolve_element_object_id(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(String, String), String> {
if let Some(ref_id) = parse_ref(selector_or_ref) {
let entry = ref_map
.get(&ref_id)
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
let effective_session_id =
resolve_frame_session(entry.frame_id.as_deref(), session_id, iframe_sessions);
if let Some(backend_node_id) = entry.backend_node_id {
let result: Result<DomResolveNodeResult, String> = client
.send_command_typed(
"DOM.resolveNode",
&DomResolveNodeParams {
backend_node_id: Some(backend_node_id),
node_id: None,
object_group: Some("browser-automation-cli".to_string()),
},
Some(effective_session_id),
)
.await;
if let Ok(r) = result {
if let Some(object_id) = r.object.object_id {
return Ok((object_id, effective_session_id.to_string()));
}
}
}
let fresh_id = find_node_id_by_role_name(
client,
session_id,
&entry.role,
&entry.name,
entry.nth,
entry.frame_id.as_deref(),
iframe_sessions,
)
.await?;
let result: DomResolveNodeResult = client
.send_command_typed(
"DOM.resolveNode",
&DomResolveNodeParams {
backend_node_id: Some(fresh_id),
node_id: None,
object_group: Some("browser-automation-cli".to_string()),
},
Some(effective_session_id),
)
.await?;
let object_id = result
.object
.object_id
.ok_or_else(|| format!("No objectId for ref {}", ref_id))?;
return Ok((object_id, effective_session_id.to_string()));
}
if let Some(frame_id) = active_frame() {
if let Some(frame_session) = iframe_sessions.get(&frame_id) {
let js = build_find_element_js(selector_or_ref);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(false),
await_promise: Some(false),
},
Some(frame_session.as_str()),
)
.await?;
let object_id = object_id_from_evaluate(result, selector_or_ref)?;
return Ok((object_id, frame_session.clone()));
}
let object_id =
resolve_object_in_same_process_frame(client, session_id, &frame_id, selector_or_ref)
.await?;
return Ok((object_id, session_id.to_string()));
}
let js = build_find_element_js(selector_or_ref);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(false),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let object_id = object_id_from_evaluate(result, selector_or_ref)?;
Ok((object_id, session_id.to_string()))
}
pub(super) fn resolve_ax_session<'a>(
frame_id: Option<&str>,
session_id: &'a str,
iframe_sessions: &'a HashMap<String, String>,
) -> (serde_json::Value, &'a str) {
if let Some(frame_id) = frame_id {
if let Some(iframe_sid) = iframe_sessions.get(frame_id) {
(serde_json::json!({}), iframe_sid.as_str())
} else {
(serde_json::json!({ "frameId": frame_id }), session_id)
}
} else {
(serde_json::json!({}), session_id)
}
}
fn resolve_frame_session<'a>(
frame_id: Option<&str>,
session_id: &'a str,
iframe_sessions: &'a HashMap<String, String>,
) -> &'a str {
frame_id
.and_then(|fid| iframe_sessions.get(fid))
.map(|s| s.as_str())
.unwrap_or(session_id)
}
async fn find_node_id_by_role_name(
client: &CdpClient,
session_id: &str,
role: &str,
name: &str,
nth: Option<usize>,
frame_id: Option<&str>,
iframe_sessions: &HashMap<String, String>,
) -> Result<i64, String> {
let (ax_params, effective_session_id) =
resolve_ax_session(frame_id, session_id, iframe_sessions);
let ax_tree: GetFullAXTreeResult = client
.send_command_typed(
"Accessibility.getFullAXTree",
&ax_params,
Some(effective_session_id),
)
.await?;
let nth_index = nth.unwrap_or(0);
let mut match_count: usize = 0;
for node in &ax_tree.nodes {
if node.ignored.unwrap_or(false) {
continue;
}
let node_role = extract_ax_string(&node.role);
let node_name = extract_ax_string(&node.name);
if node_role == role && node_name == name {
if match_count == nth_index {
return node.backend_d_o_m_node_id.ok_or_else(|| {
format!(
"AX node has no backendDOMNodeId for role={} name={}",
role, name
)
});
}
match_count += 1;
}
}
Err(format!(
"Could not locate element with role={} name={}",
role, name
))
}
pub(super) fn extract_ax_string(value: &Option<AXValue>) -> String {
match value {
Some(v) => match &v.value {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
_ => String::new(),
},
None => String::new(),
}
}
fn normalize_css_selector(selector: &str) -> &str {
selector
.strip_prefix("css=")
.or_else(|| selector.strip_prefix("Css="))
.unwrap_or(selector)
}
fn build_find_element_js(selector: &str) -> String {
build_find_element_js_in("document", selector)
}
fn build_find_element_js_in(root: &str, selector: &str) -> String {
if let Some(xpath) = selector.strip_prefix("xpath=") {
format!(
"{root}.evaluate({xpath}, {root}, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue",
xpath = serde_json::to_string(xpath).unwrap_or_default(),
)
} else {
let css = normalize_css_selector(selector);
format!(
"{root}.querySelector({selector})",
selector = serde_json::to_string(css).unwrap_or_default(),
)
}
}
fn build_count_elements_js(selector: &str) -> String {
if let Some(xpath) = selector.strip_prefix("xpath=") {
format!(
"document.evaluate({}, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength",
serde_json::to_string(xpath).unwrap_or_default()
)
} else {
let css = normalize_css_selector(selector);
format!(
"document.querySelectorAll({}).length",
serde_json::to_string(css).unwrap_or_default()
)
}
}
fn object_id_from_evaluate(result: EvaluateResult, selector: &str) -> Result<String, String> {
if let Some(exc) = result.exception_details {
let detail = exc
.exception
.as_ref()
.and_then(|e| e.description.clone())
.filter(|s| !s.is_empty())
.unwrap_or(exc.text);
return Err(format!("Element not found: {selector} ({detail})"));
}
result
.result
.object_id
.ok_or_else(|| format!("Element not found: {selector}"))
}
const BLOCKER_AT_JS: &str = r#"(doc, el, x, y) => {
// Descend from the given document through same-origin iframes so a point
// over a frame resolves to the element inside it, in that frame's space.
let d = doc, lx = x, ly = y;
let hit = d.elementFromPoint(lx, ly);
while (hit && (hit.tagName === 'IFRAME' || hit.tagName === 'FRAME') && hit.contentDocument && hit !== el) {
const r = hit.getBoundingClientRect();
lx -= r.x + hit.clientLeft;
ly -= r.y + hit.clientTop;
d = hit.contentDocument;
hit = d.elementFromPoint(lx, ly);
}
if (!hit || hit === el) return null;
const up = (n) => n.parentNode || n.host || (n.getRootNode && n.getRootNode().host) || null;
for (let n = hit; n; n = up(n)) { if (n === el) return null; }
for (let n = el; n; n = up(n)) { if (n === hit) return null; }
const hitLabel = hit.closest ? hit.closest('label') : null;
if (hitLabel && (hitLabel.control === el || hitLabel.contains(el))) return null;
const elLabel = el.closest ? el.closest('label') : null;
if (elLabel && elLabel.contains(hit)) return null;
let desc = hit.tagName.toLowerCase();
if (hit.id) desc += '#' + hit.id;
else if (typeof hit.className === 'string' && hit.className.trim())
desc += '.' + hit.className.trim().split(/\s+/).slice(0, 2).join('.');
if (!hit.id && hit.closest) {
const anchored = hit.closest('[id]');
if (anchored && anchored !== hit)
desc += ' inside ' + anchored.tagName.toLowerCase() + '#' + anchored.id;
}
return desc;
}"#;
fn build_selector_js(selector: &str) -> String {
let find_expr = build_find_element_js(selector);
format!(
r#"(() => {{
const el = {find_expr};
if (!el) return null;
const inView = (r) => r.width > 0 && r.height > 0 &&
r.bottom > 0 && r.right > 0 &&
r.top < (window.innerHeight || document.documentElement.clientHeight) &&
r.left < (window.innerWidth || document.documentElement.clientWidth);
let rect = el.getBoundingClientRect();
if (!inView(rect)) {{
el.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
rect = el.getBoundingClientRect();
}}
const x = rect.x + rect.width / 2;
const y = rect.y + rect.height / 2;
const blockerAt = {BLOCKER_AT_JS};
return {{ x: x, y: y, blocker: blockerAt(document, el, x, y) }};
}})()"#,
)
}
async fn resolve_by_selector(
client: &CdpClient,
session_id: &str,
selector: &str,
) -> Result<(f64, f64), String> {
let js = build_selector_js(selector);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
if let Some(exc) = result.exception_details {
let detail = exc
.exception
.as_ref()
.and_then(|e| e.description.clone())
.filter(|s| !s.is_empty())
.unwrap_or(exc.text);
return Err(format!("Element not found: {selector} ({detail})"));
}
let val = result.result.value.unwrap_or(Value::Null);
if let Some(blocker) = val.get("blocker").and_then(|v| v.as_str()) {
return Err(intercepted_error(selector, blocker));
}
let x = val.get("x").and_then(|v| v.as_f64());
let y = val.get("y").and_then(|v| v.as_f64());
match (x, y) {
(Some(x), Some(y)) => Ok((x, y)),
_ => Err(format!("Element not found: {}", selector)),
}
}
fn intercepted_error(target: &str, blocker: &str) -> String {
format!(
"Element '{}' is covered by <{}> at its click point, so the input would land on that element instead. Dismiss or interact with the covering element first (it is often a dialog, banner, or sticky header).",
target, blocker
)
}
fn box_model_center(model: &BoxModel) -> (f64, f64) {
if model.content.len() >= 8 {
let x = (model.content[0] + model.content[2] + model.content[4] + model.content[6]) / 4.0;
let y = (model.content[1] + model.content[3] + model.content[5] + model.content[7]) / 4.0;
(x, y)
} else {
(0.0, 0.0)
}
}
pub async fn get_element_text(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration:
"function() { return this.innerText || this.textContent || ''; }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default())
}
pub async fn get_element_attribute(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
attribute: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<Value, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let attr_name = serde_json::to_string(attribute).unwrap_or_else(|_| "\"\"".into());
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: format!(
"function() {{ \
var n = {attr_name}; \
var a = this.getAttribute ? this.getAttribute(n) : null; \
if (a !== null && a !== undefined) return a; \
try {{ var p = this[n]; if (p !== undefined && p !== null) return p; }} catch (e) {{}} \
return null; \
}}"
),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result.result.value.unwrap_or(Value::Null))
}
pub async fn is_element_visible(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<bool, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
const rect = this.getBoundingClientRect();
const style = window.getComputedStyle(this);
return rect.width > 0 && rect.height > 0 &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
parseFloat(style.opacity) > 0;
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_bool())
.unwrap_or(false))
}
pub async fn is_element_enabled(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<bool, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { return !this.disabled; }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_bool())
.unwrap_or(true))
}
pub async fn is_element_checked(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<bool, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
var el = this;
// Native checkbox/radio input
var tag = el.tagName && el.tagName.toUpperCase();
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
return el.checked;
}
// ARIA role-based checked state
var role = el.getAttribute && el.getAttribute('role');
var ariaCheckedRoles = ['checkbox','radio','switch','menuitemcheckbox','menuitemradio','option','treeitem'];
if (role && ariaCheckedRoles.indexOf(role) !== -1) {
return el.getAttribute('aria-checked') === 'true';
}
// Follow label association (Playwright follow-label retarget)
var label = el;
if (tag !== 'LABEL') {
label = el.closest && el.closest('label');
}
if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
var ctrl = label.control;
if (ctrl.type === 'checkbox' || ctrl.type === 'radio') {
return ctrl.checked;
}
}
// Check for nested native input
var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
if (input) return input.checked;
return false;
}"#.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_bool())
.unwrap_or(false))
}
pub async fn get_element_inner_text(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { return this.innerText || ''; }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default())
}
pub async fn get_element_inner_html(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: "function() { return this.innerHTML || ''; }".to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default())
}
pub async fn get_element_input_value(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration:
"function() { return typeof this.value === 'string' ? this.value : ''; }"
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result
.result
.value
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default())
}
pub async fn set_element_value(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
value: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let js = format!(
"function() {{ this.value = {}; this.dispatchEvent(new Event('input', {{bubbles: true}})); this.dispatchEvent(new Event('change', {{bubbles: true}})); }}",
serde_json::to_string(value).unwrap_or_default()
);
client
.send_command_typed::<_, EvaluateResult>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(())
}
pub async fn get_element_bounding_box(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<Value, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
const r = this.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
result
.result
.value
.ok_or_else(|| format!("Could not get bounding box for: {}", selector_or_ref))
}
pub async fn get_element_count(
client: &CdpClient,
session_id: &str,
selector: &str,
) -> Result<i64, String> {
let js = build_count_elements_js(selector);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
Ok(result.result.value.and_then(|v| v.as_i64()).unwrap_or(0))
}
pub async fn get_element_styles(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
properties: Option<Vec<String>>,
iframe_sessions: &HashMap<String, String>,
) -> Result<Value, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let js = match properties {
Some(props) => {
let props_json = serde_json::to_string(&props).unwrap_or("[]".to_string());
format!(
r#"function() {{
const s = window.getComputedStyle(this);
const props = {};
const result = {{}};
for (const p of props) result[p] = s.getPropertyValue(p);
return result;
}}"#,
props_json
)
}
None => r#"function() {
const s = window.getComputedStyle(this);
const result = {};
for (let i = 0; i < s.length; i++) {
const p = s[i];
result[p] = s.getPropertyValue(p);
}
return result;
}"#
.to_string(),
};
let result: EvaluateResult = client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result.result.value.unwrap_or(Value::Null))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ref_at_prefix() {
assert_eq!(parse_ref("@e1"), Some("e1".to_string()));
assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
}
#[test]
fn test_parse_ref_equals_prefix() {
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
}
#[test]
fn test_parse_ref_bare() {
assert_eq!(parse_ref("e1"), Some("e1".to_string()));
assert_eq!(parse_ref("e42"), Some("e42".to_string()));
}
#[test]
fn test_parse_ref_invalid() {
assert_eq!(parse_ref("button"), None);
assert_eq!(parse_ref("e"), None);
assert_eq!(parse_ref("1"), None);
assert_eq!(parse_ref(""), None);
}
#[test]
fn test_ref_map_basic() {
let mut map = RefMap::new();
map.add("e1".to_string(), Some(42), "button", "Submit", None);
assert!(map.get("e1").is_some());
assert_eq!(map.get("e1").unwrap().role, "button");
assert!(map.get("e2").is_none());
}
#[test]
fn test_normalize_css_prefix_strips_playwright_style() {
assert_eq!(normalize_css_selector("css=h1"), "h1");
assert_eq!(normalize_css_selector("h1"), "h1");
assert_eq!(normalize_css_selector("css=.foo > bar"), ".foo > bar");
}
#[test]
fn test_build_find_element_js_strips_css_prefix() {
let js = build_find_element_js("css=h1");
assert!(js.contains("document.querySelector(\"h1\")"), "got {js}");
assert!(!js.contains("css=h1"), "got {js}");
}
#[test]
fn test_object_id_from_evaluate_rejects_exception() {
let result = EvaluateResult {
result: RemoteObject {
object_type: "object".into(),
subtype: Some("error".into()),
value: None,
description: Some("SyntaxError".into()),
object_id: Some("err.1".into()),
class_name: Some("SyntaxError".into()),
unserializable_value: None,
preview: None,
},
exception_details: Some(ExceptionDetails {
text: "Uncaught".into(),
exception: Some(RemoteObject {
object_type: "object".into(),
subtype: Some("error".into()),
value: None,
description: Some("not a valid selector".into()),
object_id: Some("err.1".into()),
class_name: Some("SyntaxError".into()),
unserializable_value: None,
preview: None,
}),
line_number: None,
column_number: None,
}),
};
let err = object_id_from_evaluate(result, "css=h1").unwrap_err();
assert!(err.contains("Element not found"), "{err}");
assert!(err.contains("not a valid selector"), "{err}");
}
#[test]
fn test_build_selector_js_css() {
let js = build_selector_js("#submit-btn");
assert!(js.contains("document.querySelector(\"#submit-btn\")"));
assert!(!js.contains("document.evaluate"));
}
#[test]
fn test_build_selector_js_xpath() {
let js = build_selector_js("xpath=//button[@id='ok']");
assert!(js.contains("document.evaluate(\"//button[@id='ok']\", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)"));
assert!(!js.contains("document.querySelector"));
}
#[test]
fn test_build_selector_js_xpath_empty() {
let js = build_selector_js("xpath=");
assert!(js.contains("document.evaluate"));
}
#[test]
fn test_build_selector_js_not_xpath_prefix() {
let js = build_selector_js("xpath//div");
assert!(js.contains("document.querySelector"));
}
#[test]
fn test_build_count_elements_js_css() {
let js = build_count_elements_js(".item");
assert!(js.contains("document.querySelectorAll(\".item\").length"));
assert!(!js.contains("document.evaluate"));
}
#[test]
fn test_build_count_elements_js_xpath() {
let js = build_count_elements_js("xpath=//li");
assert!(js.contains("document.evaluate(\"//li\", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength"));
assert!(!js.contains("querySelectorAll"));
}
#[test]
fn test_box_model_center() {
let model = BoxModel {
content: vec![10.0, 20.0, 110.0, 20.0, 110.0, 60.0, 10.0, 60.0],
padding: vec![],
border: vec![],
margin: vec![],
width: 100,
height: 40,
};
let (x, y) = box_model_center(&model);
assert!((x - 60.0).abs() < 0.01);
assert!((y - 40.0).abs() < 0.01);
}
#[test]
fn test_cross_origin_element_uses_dedicated_session() {
let mut iframe_sessions = HashMap::new();
iframe_sessions.insert(
"cross-origin-frame".to_string(),
"iframe-session".to_string(),
);
let session = resolve_frame_session(
Some("cross-origin-frame"),
"parent-session",
&iframe_sessions,
);
assert_eq!(session, "iframe-session");
}
#[test]
fn test_same_origin_element_uses_parent_session() {
let iframe_sessions = HashMap::new();
let session = resolve_frame_session(
Some("same-origin-frame"),
"parent-session",
&iframe_sessions,
);
assert_eq!(session, "parent-session");
}
#[test]
fn test_main_frame_element_uses_parent_session() {
let iframe_sessions = HashMap::new();
let session = resolve_frame_session(None, "parent-session", &iframe_sessions);
assert_eq!(session, "parent-session");
}
}