1#![allow(missing_docs)]
10use rustc_hash::FxHashMap;
11use serde_json::Value;
12
13use super::cdp::client::CdpClient;
14use super::cdp::types::*;
15
16#[derive(Debug, Clone)]
17pub struct RefEntry {
18 pub backend_node_id: Option<i64>,
19 pub role: String,
20 pub name: String,
21 pub nth: Option<usize>,
22 pub selector: Option<String>,
23 pub frame_id: Option<String>,
24}
25
26pub struct RefMap {
31 map: FxHashMap<String, RefEntry>,
32 next_ref: usize,
33}
34
35impl Default for RefMap {
36 fn default() -> Self {
37 Self::new()
38 }
39}
40
41impl RefMap {
42 pub fn new() -> Self {
43 Self {
44 map: FxHashMap::default(),
45 next_ref: 1,
46 }
47 }
48
49 pub fn add(
50 &mut self,
51 ref_id: String,
52 backend_node_id: Option<i64>,
53 role: &str,
54 name: &str,
55 nth: Option<usize>,
56 ) {
57 self.add_with_frame(ref_id, backend_node_id, role, name, nth, None);
58 }
59
60 pub fn add_with_frame(
61 &mut self,
62 ref_id: String,
63 backend_node_id: Option<i64>,
64 role: &str,
65 name: &str,
66 nth: Option<usize>,
67 frame_id: Option<&str>,
68 ) {
69 self.map.insert(
70 ref_id,
71 RefEntry {
72 backend_node_id,
73 role: role.to_string(),
74 name: name.to_string(),
75 nth,
76 selector: None,
77 frame_id: frame_id.map(|s| s.to_string()),
78 },
79 );
80 }
81
82 pub fn add_selector(
83 &mut self,
84 ref_id: String,
85 selector: String,
86 role: &str,
87 name: &str,
88 nth: Option<usize>,
89 ) {
90 self.map.insert(
91 ref_id,
92 RefEntry {
93 backend_node_id: None,
94 role: role.to_string(),
95 name: name.to_string(),
96 nth,
97 selector: Some(selector),
98 frame_id: None,
99 },
100 );
101 }
102
103 pub fn get(&self, ref_id: &str) -> Option<&RefEntry> {
104 self.map.get(ref_id)
105 }
106
107 pub fn entries_sorted(&self) -> Vec<(String, RefEntry)> {
108 let mut entries = self
109 .map
110 .iter()
111 .map(|(ref_id, entry)| (ref_id.clone(), entry.clone()))
112 .collect::<Vec<_>>();
113
114 entries.sort_by_key(|(ref_id, _)| {
115 ref_id
116 .strip_prefix('e')
117 .and_then(|n| n.parse::<usize>().ok())
118 .unwrap_or(usize::MAX)
119 });
120
121 entries
122 }
123
124 pub fn remove(&mut self, ref_id: &str) {
125 self.map.remove(ref_id);
126 }
127
128 pub fn clear(&mut self) {
129 self.map.clear();
130 self.next_ref = 1;
131 }
132
133 pub fn next_ref_num(&self) -> usize {
134 self.next_ref
135 }
136
137 pub fn set_next_ref_num(&mut self, n: usize) {
138 self.next_ref = n;
139 }
140}
141
142pub fn parse_ref(input: &str) -> Option<String> {
143 let trimmed = input.trim();
144
145 if let Some(stripped) = trimmed.strip_prefix('@') {
146 if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
147 return Some(stripped.to_string());
148 }
149 }
150
151 if let Some(stripped) = trimmed.strip_prefix("ref=") {
152 if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
153 return Some(stripped.to_string());
154 }
155 }
156
157 if trimmed.starts_with('e')
158 && trimmed.len() > 1
159 && trimmed[1..].chars().all(|c| c.is_ascii_digit())
160 {
161 return Some(trimmed.to_string());
162 }
163
164 None
165}
166
167static ACTIVE_FRAME: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
183
184pub fn set_active_frame(frame_id: Option<&str>) {
185 match ACTIVE_FRAME.lock() {
186 Ok(mut guard) => *guard = frame_id.map(String::from),
187 Err(poisoned) => {
188 let mut guard = poisoned.into_inner();
189 *guard = frame_id.map(String::from);
190 }
191 }
192}
193
194fn active_frame() -> Option<String> {
195 match ACTIVE_FRAME.lock() {
196 Ok(g) => g.clone(),
197 Err(poisoned) => poisoned.into_inner().clone(),
198 }
199}
200
201pub(super) async fn frame_owner_object_id(
205 client: &CdpClient,
206 session_id: &str,
207 frame_id: &str,
208) -> Result<String, String> {
209 let owner = client
210 .send_command(
211 "DOM.getFrameOwner",
212 Some(serde_json::json!({ "frameId": frame_id })),
213 Some(session_id),
214 )
215 .await?;
216 let backend_node_id = owner
217 .get("backendNodeId")
218 .and_then(|v| v.as_i64())
219 .ok_or_else(|| format!("Could not resolve the owner element of frame {}", frame_id))?;
220 let result: DomResolveNodeResult = client
221 .send_command_typed(
222 "DOM.resolveNode",
223 &DomResolveNodeParams {
224 backend_node_id: Some(backend_node_id),
225 node_id: None,
226 object_group: Some("browser-automation-cli".to_string()),
227 },
228 Some(session_id),
229 )
230 .await?;
231 result
232 .object
233 .object_id
234 .ok_or_else(|| format!("No objectId for the owner element of frame {}", frame_id))
235}
236
237async fn resolve_center_in_same_process_frame(
242 client: &CdpClient,
243 session_id: &str,
244 frame_id: &str,
245 selector: &str,
246) -> Result<(f64, f64), String> {
247 let owner_object_id = frame_owner_object_id(client, session_id, frame_id).await?;
248 let find_expr = build_find_element_js_in("doc", selector);
249 let function = format!(
250 r#"function() {{
251 const doc = this.contentDocument;
252 if (!doc) return null;
253 const el = {find_expr};
254 if (!el) return null;
255 if (el.scrollIntoViewIfNeeded) el.scrollIntoViewIfNeeded(true);
256 else el.scrollIntoView({{ block: 'center', inline: 'center' }});
257 const rect = el.getBoundingClientRect();
258 let x = rect.x + rect.width / 2;
259 let y = rect.y + rect.height / 2;
260 let win = doc.defaultView;
261 while (win && win.frameElement) {{
262 const frameRect = win.frameElement.getBoundingClientRect();
263 x += frameRect.x + win.frameElement.clientLeft;
264 y += frameRect.y + win.frameElement.clientTop;
265 win = win.parent;
266 }}
267 const blockerAt = {BLOCKER_AT_JS};
268 const topDoc = win ? win.document : doc;
269 return {{ x: x, y: y, blocker: blockerAt(topDoc, el, x, y) }};
270 }}"#,
271 );
272 let result = client
273 .send_command(
274 "Runtime.callFunctionOn",
275 Some(serde_json::json!({
276 "objectId": owner_object_id,
277 "functionDeclaration": function,
278 "returnByValue": true,
279 })),
280 Some(session_id),
281 )
282 .await?;
283 let value = result.get("result").and_then(|r| r.get("value"));
284 if let Some(blocker) = value
285 .and_then(|v| v.get("blocker"))
286 .and_then(|v| v.as_str())
287 {
288 return Err(intercepted_error(selector, blocker));
289 }
290 let x = value.and_then(|v| v.get("x")).and_then(|v| v.as_f64());
291 let y = value.and_then(|v| v.get("y")).and_then(|v| v.as_f64());
292 match (x, y) {
293 (Some(x), Some(y)) => Ok((x, y)),
294 _ => Err(format!(
295 "Element not found in the selected frame: {}",
296 selector
297 )),
298 }
299}
300
301async fn resolve_object_in_same_process_frame(
303 client: &CdpClient,
304 session_id: &str,
305 frame_id: &str,
306 selector: &str,
307) -> Result<String, String> {
308 let owner_object_id = frame_owner_object_id(client, session_id, frame_id).await?;
309 let find_expr = build_find_element_js_in("doc", selector);
310 let function = format!(
311 "function() {{ const doc = this.contentDocument; if (!doc) return null; return {find_expr}; }}",
312 );
313 let result = client
314 .send_command(
315 "Runtime.callFunctionOn",
316 Some(serde_json::json!({
317 "objectId": owner_object_id,
318 "functionDeclaration": function,
319 "returnByValue": false,
320 })),
321 Some(session_id),
322 )
323 .await?;
324 result
325 .get("result")
326 .and_then(|r| r.get("objectId"))
327 .and_then(|v| v.as_str())
328 .map(String::from)
329 .ok_or_else(|| format!("Element not found in the selected frame: {}", selector))
330}
331
332pub async fn resolve_element_center(
333 client: &CdpClient,
334 session_id: &str,
335 ref_map: &RefMap,
336 selector_or_ref: &str,
337 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
338) -> Result<(f64, f64, String), String> {
339 if let Some(ref_id) = parse_ref(selector_or_ref) {
340 let entry = ref_map
341 .get(&ref_id)
342 .ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
343
344 let effective_session_id =
345 resolve_frame_session(entry.frame_id.as_deref(), session_id, iframe_sessions);
346
347 if let Some(backend_node_id) = entry.backend_node_id {
349 scroll_node_into_view(client, effective_session_id, backend_node_id).await;
350 let result: Result<DomGetBoxModelResult, String> = client
351 .send_command_typed(
352 "DOM.getBoxModel",
353 &DomGetBoxModelParams {
354 backend_node_id: Some(backend_node_id),
355 node_id: None,
356 object_id: None,
357 },
358 Some(effective_session_id),
359 )
360 .await;
361
362 if let Ok(r) = result {
363 let (x, y) = box_model_center(&r.model);
364 check_node_interception(
365 client,
366 effective_session_id,
367 backend_node_id,
368 selector_or_ref,
369 x,
370 y,
371 )
372 .await?;
373 return Ok((x, y, effective_session_id.to_string()));
374 }
375 }
377
378 let fresh_id = find_node_id_by_role_name(
380 client,
381 session_id,
382 &entry.role,
383 &entry.name,
384 entry.nth,
385 entry.frame_id.as_deref(),
386 iframe_sessions,
387 )
388 .await?;
389 scroll_node_into_view(client, effective_session_id, fresh_id).await;
390 let result: DomGetBoxModelResult = client
391 .send_command_typed(
392 "DOM.getBoxModel",
393 &DomGetBoxModelParams {
394 backend_node_id: Some(fresh_id),
395 node_id: None,
396 object_id: None,
397 },
398 Some(effective_session_id),
399 )
400 .await?;
401 let (x, y) = box_model_center(&result.model);
402 check_node_interception(
403 client,
404 effective_session_id,
405 fresh_id,
406 selector_or_ref,
407 x,
408 y,
409 )
410 .await?;
411 return Ok((x, y, effective_session_id.to_string()));
412 }
413
414 if let Some(frame_id) = active_frame() {
416 if let Some(frame_session) = iframe_sessions.get(&frame_id) {
419 let (x, y) = resolve_by_selector(client, frame_session, selector_or_ref).await?;
420 return Ok((x, y, frame_session.clone()));
421 }
422 let (x, y) =
423 resolve_center_in_same_process_frame(client, session_id, &frame_id, selector_or_ref)
424 .await?;
425 return Ok((x, y, session_id.to_string()));
426 }
427 let (x, y) = resolve_by_selector(client, session_id, selector_or_ref).await?;
428 Ok((x, y, session_id.to_string()))
429}
430
431async fn check_node_interception(
436 client: &CdpClient,
437 session_id: &str,
438 backend_node_id: i64,
439 target: &str,
440 x: f64,
441 y: f64,
442) -> Result<(), String> {
443 let resolved: Result<DomResolveNodeResult, String> = client
444 .send_command_typed(
445 "DOM.resolveNode",
446 &DomResolveNodeParams {
447 backend_node_id: Some(backend_node_id),
448 node_id: None,
449 object_group: Some("browser-automation-cli".to_string()),
450 },
451 Some(session_id),
452 )
453 .await;
454 let Ok(resolved) = resolved else {
455 return Ok(());
456 };
457 let Some(object_id) = resolved.object.object_id else {
458 return Ok(());
459 };
460 let function = format!(
465 r#"function(x, y) {{
466 let topDoc = this.ownerDocument || document;
467 while (topDoc.defaultView && topDoc.defaultView.frameElement) {{
468 topDoc = topDoc.defaultView.frameElement.ownerDocument;
469 }}
470 const blockerAt = {BLOCKER_AT_JS};
471 return blockerAt(topDoc, this, x, y);
472 }}"#,
473 );
474 let result = client
475 .send_command(
476 "Runtime.callFunctionOn",
477 Some(serde_json::json!({
478 "objectId": object_id,
479 "functionDeclaration": function,
480 "arguments": [{ "value": x }, { "value": y }],
481 "returnByValue": true,
482 })),
483 Some(session_id),
484 )
485 .await;
486 if let Ok(value) = result {
487 if let Some(blocker) = value
488 .get("result")
489 .and_then(|r| r.get("value"))
490 .and_then(|v| v.as_str())
491 {
492 return Err(intercepted_error(target, blocker));
493 }
494 }
495 Ok(())
496}
497
498async fn scroll_node_into_view(client: &CdpClient, session_id: &str, backend_node_id: i64) {
503 let _ = client
504 .send_command(
505 "DOM.scrollIntoViewIfNeeded",
506 Some(serde_json::json!({ "backendNodeId": backend_node_id })),
507 Some(session_id),
508 )
509 .await;
510}
511
512pub async fn resolve_element_object_id(
513 client: &CdpClient,
514 session_id: &str,
515 ref_map: &RefMap,
516 selector_or_ref: &str,
517 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
518) -> Result<(String, String), String> {
519 if let Some(ref_id) = parse_ref(selector_or_ref) {
520 let entry = ref_map
521 .get(&ref_id)
522 .ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
523
524 let effective_session_id =
525 resolve_frame_session(entry.frame_id.as_deref(), session_id, iframe_sessions);
526
527 if let Some(backend_node_id) = entry.backend_node_id {
529 let result: Result<DomResolveNodeResult, String> = client
530 .send_command_typed(
531 "DOM.resolveNode",
532 &DomResolveNodeParams {
533 backend_node_id: Some(backend_node_id),
534 node_id: None,
535 object_group: Some("browser-automation-cli".to_string()),
536 },
537 Some(effective_session_id),
538 )
539 .await;
540
541 if let Ok(r) = result {
542 if let Some(object_id) = r.object.object_id {
543 return Ok((object_id, effective_session_id.to_string()));
544 }
545 }
546 }
548
549 let fresh_id = find_node_id_by_role_name(
551 client,
552 session_id,
553 &entry.role,
554 &entry.name,
555 entry.nth,
556 entry.frame_id.as_deref(),
557 iframe_sessions,
558 )
559 .await?;
560 let result: DomResolveNodeResult = client
561 .send_command_typed(
562 "DOM.resolveNode",
563 &DomResolveNodeParams {
564 backend_node_id: Some(fresh_id),
565 node_id: None,
566 object_group: Some("browser-automation-cli".to_string()),
567 },
568 Some(effective_session_id),
569 )
570 .await?;
571 let object_id = result
572 .object
573 .object_id
574 .ok_or_else(|| format!("No objectId for ref {}", ref_id))?;
575 return Ok((object_id, effective_session_id.to_string()));
576 }
577
578 if let Some(frame_id) = active_frame() {
580 if let Some(frame_session) = iframe_sessions.get(&frame_id) {
581 let js = build_find_element_js(selector_or_ref);
582 let result: EvaluateResult = client
583 .send_command_typed(
584 "Runtime.evaluate",
585 &EvaluateParams {
586 expression: js,
587 return_by_value: Some(false),
588 await_promise: Some(false),
589 },
590 Some(frame_session.as_str()),
591 )
592 .await?;
593 let object_id = object_id_from_evaluate(result, selector_or_ref)?;
594 return Ok((object_id, frame_session.clone()));
595 }
596 let object_id =
597 resolve_object_in_same_process_frame(client, session_id, &frame_id, selector_or_ref)
598 .await?;
599 return Ok((object_id, session_id.to_string()));
600 }
601
602 let js = build_find_element_js(selector_or_ref);
603 let result: EvaluateResult = client
604 .send_command_typed(
605 "Runtime.evaluate",
606 &EvaluateParams {
607 expression: js,
608 return_by_value: Some(false),
609 await_promise: Some(false),
610 },
611 Some(session_id),
612 )
613 .await?;
614
615 let object_id = object_id_from_evaluate(result, selector_or_ref)?;
616 Ok((object_id, session_id.to_string()))
617}
618
619pub(super) fn resolve_ax_session<'a>(
623 frame_id: Option<&str>,
624 session_id: &'a str,
625 iframe_sessions: &'a rustc_hash::FxHashMap<String, String>,
626) -> (serde_json::Value, &'a str) {
627 if let Some(frame_id) = frame_id {
628 if let Some(iframe_sid) = iframe_sessions.get(frame_id) {
629 (serde_json::json!({}), iframe_sid.as_str())
630 } else {
631 (serde_json::json!({ "frameId": frame_id }), session_id)
632 }
633 } else {
634 (serde_json::json!({}), session_id)
635 }
636}
637
638fn resolve_frame_session<'a>(
642 frame_id: Option<&str>,
643 session_id: &'a str,
644 iframe_sessions: &'a rustc_hash::FxHashMap<String, String>,
645) -> &'a str {
646 frame_id
647 .and_then(|fid| iframe_sessions.get(fid))
648 .map(|s| s.as_str())
649 .unwrap_or(session_id)
650}
651
652async fn find_node_id_by_role_name(
657 client: &CdpClient,
658 session_id: &str,
659 role: &str,
660 name: &str,
661 nth: Option<usize>,
662 frame_id: Option<&str>,
663 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
664) -> Result<i64, String> {
665 let (ax_params, effective_session_id) =
666 resolve_ax_session(frame_id, session_id, iframe_sessions);
667 let ax_tree: GetFullAXTreeResult = client
668 .send_command_typed(
669 "Accessibility.getFullAXTree",
670 &ax_params,
671 Some(effective_session_id),
672 )
673 .await?;
674
675 let nth_index = nth.unwrap_or(0);
676 let mut match_count: usize = 0;
677
678 for node in &ax_tree.nodes {
679 if node.ignored.unwrap_or(false) {
680 continue;
681 }
682 let node_role = extract_ax_string(&node.role);
683 let node_name = extract_ax_string(&node.name);
684 if node_role == role && node_name == name {
685 if match_count == nth_index {
686 return node.backend_d_o_m_node_id.ok_or_else(|| {
687 format!(
688 "AX node has no backendDOMNodeId for role={} name={}",
689 role, name
690 )
691 });
692 }
693 match_count += 1;
694 }
695 }
696
697 Err(format!(
698 "Could not locate element with role={} name={}",
699 role, name
700 ))
701}
702
703pub(super) fn extract_ax_string(value: &Option<AXValue>) -> String {
704 match value {
705 Some(v) => match &v.value {
706 Some(Value::String(s)) => s.clone(),
707 Some(Value::Number(n)) => n.to_string(),
708 Some(Value::Bool(b)) => b.to_string(),
709 _ => String::new(),
710 },
711 None => String::new(),
712 }
713}
714
715fn normalize_css_selector(selector: &str) -> &str {
717 selector
718 .strip_prefix("css=")
719 .or_else(|| selector.strip_prefix("Css="))
720 .unwrap_or(selector)
721}
722
723fn build_find_element_js(selector: &str) -> String {
725 build_find_element_js_in("document", selector)
726}
727
728fn build_find_element_js_in(root: &str, selector: &str) -> String {
731 if let Some(xpath) = selector.strip_prefix("xpath=") {
732 format!(
733 "{root}.evaluate({xpath}, {root}, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue",
734 xpath = serde_json::to_string(xpath).unwrap_or_default(),
735 )
736 } else {
737 let css = normalize_css_selector(selector);
738 format!(
739 "{root}.querySelector({selector})",
740 selector = serde_json::to_string(css).unwrap_or_default(),
741 )
742 }
743}
744
745fn build_count_elements_js(selector: &str) -> String {
747 if let Some(xpath) = selector.strip_prefix("xpath=") {
748 format!(
749 "document.evaluate({}, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength",
750 serde_json::to_string(xpath).unwrap_or_default()
751 )
752 } else {
753 let css = normalize_css_selector(selector);
754 format!(
755 "document.querySelectorAll({}).length",
756 serde_json::to_string(css).unwrap_or_default()
757 )
758 }
759}
760
761fn object_id_from_evaluate(result: EvaluateResult, selector: &str) -> Result<String, String> {
763 if let Some(exc) = result.exception_details {
764 let detail = exc
765 .exception
766 .as_ref()
767 .and_then(|e| e.description.clone())
768 .filter(|s| !s.is_empty())
769 .unwrap_or(exc.text);
770 return Err(format!("Element not found: {selector} ({detail})"));
771 }
772 result
773 .result
774 .object_id
775 .ok_or_else(|| format!("Element not found: {selector}"))
776}
777
778const BLOCKER_AT_JS: &str = r#"(doc, el, x, y) => {
786 // Descend from the given document through same-origin iframes so a point
787 // over a frame resolves to the element inside it, in that frame's space.
788 let d = doc, lx = x, ly = y;
789 let hit = d.elementFromPoint(lx, ly);
790 while (hit && (hit.tagName === 'IFRAME' || hit.tagName === 'FRAME') && hit.contentDocument && hit !== el) {
791 const r = hit.getBoundingClientRect();
792 lx -= r.x + hit.clientLeft;
793 ly -= r.y + hit.clientTop;
794 d = hit.contentDocument;
795 hit = d.elementFromPoint(lx, ly);
796 }
797 if (!hit || hit === el) return null;
798 const up = (n) => n.parentNode || n.host || (n.getRootNode && n.getRootNode().host) || null;
799 for (let n = hit; n; n = up(n)) { if (n === el) return null; }
800 for (let n = el; n; n = up(n)) { if (n === hit) return null; }
801 const hitLabel = hit.closest ? hit.closest('label') : null;
802 if (hitLabel && (hitLabel.control === el || hitLabel.contains(el))) return null;
803 const elLabel = el.closest ? el.closest('label') : null;
804 if (elLabel && elLabel.contains(hit)) return null;
805 let desc = hit.tagName.toLowerCase();
806 if (hit.id) desc += '#' + hit.id;
807 else if (typeof hit.className === 'string' && hit.className.trim())
808 desc += '.' + hit.className.trim().split(/\s+/).slice(0, 2).join('.');
809 if (!hit.id && hit.closest) {
810 const anchored = hit.closest('[id]');
811 if (anchored && anchored !== hit)
812 desc += ' inside ' + anchored.tagName.toLowerCase() + '#' + anchored.id;
813 }
814 return desc;
815}"#;
816
817fn build_selector_js(selector: &str) -> String {
818 let find_expr = build_find_element_js(selector);
819 format!(
824 r#"(() => {{
825 const el = {find_expr};
826 if (!el) return null;
827 const inView = (r) => r.width > 0 && r.height > 0 &&
828 r.bottom > 0 && r.right > 0 &&
829 r.top < (window.innerHeight || document.documentElement.clientHeight) &&
830 r.left < (window.innerWidth || document.documentElement.clientWidth);
831 let rect = el.getBoundingClientRect();
832 if (!inView(rect)) {{
833 el.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
834 rect = el.getBoundingClientRect();
835 }}
836 const x = rect.x + rect.width / 2;
837 const y = rect.y + rect.height / 2;
838 const blockerAt = {BLOCKER_AT_JS};
839 return {{ x: x, y: y, blocker: blockerAt(document, el, x, y) }};
840 }})()"#,
841 )
842}
843
844async fn resolve_by_selector(
845 client: &CdpClient,
846 session_id: &str,
847 selector: &str,
848) -> Result<(f64, f64), String> {
849 let js = build_selector_js(selector);
850
851 let result: EvaluateResult = client
852 .send_command_typed(
853 "Runtime.evaluate",
854 &EvaluateParams {
855 expression: js,
856 return_by_value: Some(true),
857 await_promise: Some(false),
858 },
859 Some(session_id),
860 )
861 .await?;
862
863 if let Some(exc) = result.exception_details {
864 let detail = exc
865 .exception
866 .as_ref()
867 .and_then(|e| e.description.clone())
868 .filter(|s| !s.is_empty())
869 .unwrap_or(exc.text);
870 return Err(format!("Element not found: {selector} ({detail})"));
871 }
872
873 let val = result.result.value.unwrap_or(Value::Null);
874 if let Some(blocker) = val.get("blocker").and_then(|v| v.as_str()) {
875 return Err(intercepted_error(selector, blocker));
876 }
877 let x = val.get("x").and_then(|v| v.as_f64());
878 let y = val.get("y").and_then(|v| v.as_f64());
879
880 match (x, y) {
881 (Some(x), Some(y)) => Ok((x, y)),
882 _ => Err(format!("Element not found: {}", selector)),
883 }
884}
885
886fn intercepted_error(target: &str, blocker: &str) -> String {
887 format!(
888 "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).",
889 target, blocker
890 )
891}
892
893fn box_model_center(model: &BoxModel) -> (f64, f64) {
894 if model.content.len() >= 8 {
896 let x = (model.content[0] + model.content[2] + model.content[4] + model.content[6]) / 4.0;
897 let y = (model.content[1] + model.content[3] + model.content[5] + model.content[7]) / 4.0;
898 (x, y)
899 } else {
900 (0.0, 0.0)
901 }
902}
903
904pub async fn get_element_text(
905 client: &CdpClient,
906 session_id: &str,
907 ref_map: &RefMap,
908 selector_or_ref: &str,
909 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
910) -> Result<String, String> {
911 let (object_id, effective_session_id) = resolve_element_object_id(
912 client,
913 session_id,
914 ref_map,
915 selector_or_ref,
916 iframe_sessions,
917 )
918 .await?;
919
920 let result: EvaluateResult = client
921 .send_command_typed(
922 "Runtime.callFunctionOn",
923 &CallFunctionOnParams {
924 function_declaration:
925 "function() { return this.innerText || this.textContent || ''; }".to_string(),
926 object_id: Some(object_id),
927 arguments: None,
928 return_by_value: Some(true),
929 await_promise: Some(false),
930 },
931 Some(&effective_session_id),
932 )
933 .await?;
934
935 Ok(result
936 .result
937 .value
938 .and_then(|v| v.as_str().map(|s| s.to_string()))
939 .unwrap_or_default())
940}
941
942pub async fn get_element_attribute(
943 client: &CdpClient,
944 session_id: &str,
945 ref_map: &RefMap,
946 selector_or_ref: &str,
947 attribute: &str,
948 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
949) -> Result<Value, String> {
950 let (object_id, effective_session_id) = resolve_element_object_id(
951 client,
952 session_id,
953 ref_map,
954 selector_or_ref,
955 iframe_sessions,
956 )
957 .await?;
958
959 let attr_name = serde_json::to_string(attribute).unwrap_or_else(|_| "\"\"".into());
960 let result: EvaluateResult = client
962 .send_command_typed(
963 "Runtime.callFunctionOn",
964 &CallFunctionOnParams {
965 function_declaration: format!(
966 "function() {{ \
967 var n = {attr_name}; \
968 var a = this.getAttribute ? this.getAttribute(n) : null; \
969 if (a !== null && a !== undefined) return a; \
970 try {{ var p = this[n]; if (p !== undefined && p !== null) return p; }} catch (e) {{}} \
971 return null; \
972 }}"
973 ),
974 object_id: Some(object_id),
975 arguments: None,
976 return_by_value: Some(true),
977 await_promise: Some(false),
978 },
979 Some(&effective_session_id),
980 )
981 .await?;
982
983 Ok(result.result.value.unwrap_or(Value::Null))
984}
985
986pub async fn is_element_visible(
987 client: &CdpClient,
988 session_id: &str,
989 ref_map: &RefMap,
990 selector_or_ref: &str,
991 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
992) -> Result<bool, String> {
993 let (object_id, effective_session_id) = resolve_element_object_id(
994 client,
995 session_id,
996 ref_map,
997 selector_or_ref,
998 iframe_sessions,
999 )
1000 .await?;
1001
1002 let result: EvaluateResult = client
1003 .send_command_typed(
1004 "Runtime.callFunctionOn",
1005 &CallFunctionOnParams {
1006 function_declaration: r#"function() {
1007 const rect = this.getBoundingClientRect();
1008 const style = window.getComputedStyle(this);
1009 return rect.width > 0 && rect.height > 0 &&
1010 style.visibility !== 'hidden' &&
1011 style.display !== 'none' &&
1012 parseFloat(style.opacity) > 0;
1013 }"#
1014 .to_string(),
1015 object_id: Some(object_id),
1016 arguments: None,
1017 return_by_value: Some(true),
1018 await_promise: Some(false),
1019 },
1020 Some(&effective_session_id),
1021 )
1022 .await?;
1023
1024 Ok(result
1025 .result
1026 .value
1027 .and_then(|v| v.as_bool())
1028 .unwrap_or(false))
1029}
1030
1031pub async fn is_element_enabled(
1032 client: &CdpClient,
1033 session_id: &str,
1034 ref_map: &RefMap,
1035 selector_or_ref: &str,
1036 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
1037) -> Result<bool, String> {
1038 let (object_id, effective_session_id) = resolve_element_object_id(
1039 client,
1040 session_id,
1041 ref_map,
1042 selector_or_ref,
1043 iframe_sessions,
1044 )
1045 .await?;
1046
1047 let result: EvaluateResult = client
1048 .send_command_typed(
1049 "Runtime.callFunctionOn",
1050 &CallFunctionOnParams {
1051 function_declaration: "function() { return !this.disabled; }".to_string(),
1052 object_id: Some(object_id),
1053 arguments: None,
1054 return_by_value: Some(true),
1055 await_promise: Some(false),
1056 },
1057 Some(&effective_session_id),
1058 )
1059 .await?;
1060
1061 Ok(result
1062 .result
1063 .value
1064 .and_then(|v| v.as_bool())
1065 .unwrap_or(true))
1066}
1067
1068pub async fn is_element_checked(
1069 client: &CdpClient,
1070 session_id: &str,
1071 ref_map: &RefMap,
1072 selector_or_ref: &str,
1073 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
1074) -> Result<bool, String> {
1075 let (object_id, effective_session_id) = resolve_element_object_id(
1076 client,
1077 session_id,
1078 ref_map,
1079 selector_or_ref,
1080 iframe_sessions,
1081 )
1082 .await?;
1083
1084 let result: EvaluateResult = client
1090 .send_command_typed(
1091 "Runtime.callFunctionOn",
1092 &CallFunctionOnParams {
1093 function_declaration: r#"function() {
1094 var el = this;
1095 // Native checkbox/radio input
1096 var tag = el.tagName && el.tagName.toUpperCase();
1097 if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
1098 return el.checked;
1099 }
1100 // ARIA role-based checked state
1101 var role = el.getAttribute && el.getAttribute('role');
1102 var ariaCheckedRoles = ['checkbox','radio','switch','menuitemcheckbox','menuitemradio','option','treeitem'];
1103 if (role && ariaCheckedRoles.indexOf(role) !== -1) {
1104 return el.getAttribute('aria-checked') === 'true';
1105 }
1106 // Follow label association (Playwright follow-label retarget)
1107 var label = el;
1108 if (tag !== 'LABEL') {
1109 label = el.closest && el.closest('label');
1110 }
1111 if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
1112 var ctrl = label.control;
1113 if (ctrl.type === 'checkbox' || ctrl.type === 'radio') {
1114 return ctrl.checked;
1115 }
1116 }
1117 // Check for nested native input
1118 var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
1119 if (input) return input.checked;
1120 return false;
1121 }"#.to_string(),
1122 object_id: Some(object_id),
1123 arguments: None,
1124 return_by_value: Some(true),
1125 await_promise: Some(false),
1126 },
1127 Some(&effective_session_id),
1128 )
1129 .await?;
1130
1131 Ok(result
1132 .result
1133 .value
1134 .and_then(|v| v.as_bool())
1135 .unwrap_or(false))
1136}
1137
1138pub async fn get_element_inner_text(
1139 client: &CdpClient,
1140 session_id: &str,
1141 ref_map: &RefMap,
1142 selector_or_ref: &str,
1143 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
1144) -> Result<String, String> {
1145 let (object_id, effective_session_id) = resolve_element_object_id(
1146 client,
1147 session_id,
1148 ref_map,
1149 selector_or_ref,
1150 iframe_sessions,
1151 )
1152 .await?;
1153
1154 let result: EvaluateResult = client
1155 .send_command_typed(
1156 "Runtime.callFunctionOn",
1157 &CallFunctionOnParams {
1158 function_declaration: "function() { return this.innerText || ''; }".to_string(),
1159 object_id: Some(object_id),
1160 arguments: None,
1161 return_by_value: Some(true),
1162 await_promise: Some(false),
1163 },
1164 Some(&effective_session_id),
1165 )
1166 .await?;
1167
1168 Ok(result
1169 .result
1170 .value
1171 .and_then(|v| v.as_str().map(|s| s.to_string()))
1172 .unwrap_or_default())
1173}
1174
1175pub async fn get_element_inner_html(
1176 client: &CdpClient,
1177 session_id: &str,
1178 ref_map: &RefMap,
1179 selector_or_ref: &str,
1180 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
1181) -> Result<String, String> {
1182 let (object_id, effective_session_id) = resolve_element_object_id(
1183 client,
1184 session_id,
1185 ref_map,
1186 selector_or_ref,
1187 iframe_sessions,
1188 )
1189 .await?;
1190
1191 let result: EvaluateResult = client
1192 .send_command_typed(
1193 "Runtime.callFunctionOn",
1194 &CallFunctionOnParams {
1195 function_declaration: "function() { return this.innerHTML || ''; }".to_string(),
1196 object_id: Some(object_id),
1197 arguments: None,
1198 return_by_value: Some(true),
1199 await_promise: Some(false),
1200 },
1201 Some(&effective_session_id),
1202 )
1203 .await?;
1204
1205 Ok(result
1206 .result
1207 .value
1208 .and_then(|v| v.as_str().map(|s| s.to_string()))
1209 .unwrap_or_default())
1210}
1211
1212pub async fn get_element_input_value(
1213 client: &CdpClient,
1214 session_id: &str,
1215 ref_map: &RefMap,
1216 selector_or_ref: &str,
1217 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
1218) -> Result<String, String> {
1219 let (object_id, effective_session_id) = resolve_element_object_id(
1220 client,
1221 session_id,
1222 ref_map,
1223 selector_or_ref,
1224 iframe_sessions,
1225 )
1226 .await?;
1227
1228 let result: EvaluateResult = client
1229 .send_command_typed(
1230 "Runtime.callFunctionOn",
1231 &CallFunctionOnParams {
1232 function_declaration:
1233 "function() { return typeof this.value === 'string' ? this.value : ''; }"
1234 .to_string(),
1235 object_id: Some(object_id),
1236 arguments: None,
1237 return_by_value: Some(true),
1238 await_promise: Some(false),
1239 },
1240 Some(&effective_session_id),
1241 )
1242 .await?;
1243
1244 Ok(result
1245 .result
1246 .value
1247 .and_then(|v| v.as_str().map(|s| s.to_string()))
1248 .unwrap_or_default())
1249}
1250
1251pub async fn set_element_value(
1252 client: &CdpClient,
1253 session_id: &str,
1254 ref_map: &RefMap,
1255 selector_or_ref: &str,
1256 value: &str,
1257 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
1258) -> Result<(), String> {
1259 let (object_id, effective_session_id) = resolve_element_object_id(
1260 client,
1261 session_id,
1262 ref_map,
1263 selector_or_ref,
1264 iframe_sessions,
1265 )
1266 .await?;
1267
1268 let js = format!(
1269 "function() {{ this.value = {}; this.dispatchEvent(new Event('input', {{bubbles: true}})); this.dispatchEvent(new Event('change', {{bubbles: true}})); }}",
1270 serde_json::to_string(value).unwrap_or_default()
1271 );
1272
1273 client
1274 .send_command_typed::<_, EvaluateResult>(
1275 "Runtime.callFunctionOn",
1276 &CallFunctionOnParams {
1277 function_declaration: js,
1278 object_id: Some(object_id),
1279 arguments: None,
1280 return_by_value: Some(true),
1281 await_promise: Some(false),
1282 },
1283 Some(&effective_session_id),
1284 )
1285 .await?;
1286
1287 Ok(())
1288}
1289
1290pub async fn get_element_bounding_box(
1291 client: &CdpClient,
1292 session_id: &str,
1293 ref_map: &RefMap,
1294 selector_or_ref: &str,
1295 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
1296) -> Result<Value, String> {
1297 let (object_id, effective_session_id) = resolve_element_object_id(
1298 client,
1299 session_id,
1300 ref_map,
1301 selector_or_ref,
1302 iframe_sessions,
1303 )
1304 .await?;
1305
1306 let result: EvaluateResult = client
1307 .send_command_typed(
1308 "Runtime.callFunctionOn",
1309 &CallFunctionOnParams {
1310 function_declaration: r#"function() {
1311 const r = this.getBoundingClientRect();
1312 return { x: r.x, y: r.y, width: r.width, height: r.height };
1313 }"#
1314 .to_string(),
1315 object_id: Some(object_id),
1316 arguments: None,
1317 return_by_value: Some(true),
1318 await_promise: Some(false),
1319 },
1320 Some(&effective_session_id),
1321 )
1322 .await?;
1323
1324 result
1325 .result
1326 .value
1327 .ok_or_else(|| format!("Could not get bounding box for: {}", selector_or_ref))
1328}
1329
1330pub async fn get_element_count(
1331 client: &CdpClient,
1332 session_id: &str,
1333 selector: &str,
1334) -> Result<i64, String> {
1335 let js = build_count_elements_js(selector);
1336
1337 let result: EvaluateResult = client
1338 .send_command_typed(
1339 "Runtime.evaluate",
1340 &EvaluateParams {
1341 expression: js,
1342 return_by_value: Some(true),
1343 await_promise: Some(false),
1344 },
1345 Some(session_id),
1346 )
1347 .await?;
1348
1349 Ok(result.result.value.and_then(|v| v.as_i64()).unwrap_or(0))
1350}
1351
1352pub async fn get_element_styles(
1353 client: &CdpClient,
1354 session_id: &str,
1355 ref_map: &RefMap,
1356 selector_or_ref: &str,
1357 properties: Option<Vec<String>>,
1358 iframe_sessions: &rustc_hash::FxHashMap<String, String>,
1359) -> Result<Value, String> {
1360 let (object_id, effective_session_id) = resolve_element_object_id(
1361 client,
1362 session_id,
1363 ref_map,
1364 selector_or_ref,
1365 iframe_sessions,
1366 )
1367 .await?;
1368
1369 let js = match properties {
1370 Some(props) => {
1371 let props_json = serde_json::to_string(&props).unwrap_or("[]".to_string());
1372 format!(
1373 r#"function() {{
1374 const s = window.getComputedStyle(this);
1375 const props = {};
1376 const result = {{}};
1377 for (const p of props) result[p] = s.getPropertyValue(p);
1378 return result;
1379 }}"#,
1380 props_json
1381 )
1382 }
1383 None => r#"function() {
1384 const s = window.getComputedStyle(this);
1385 const result = {};
1386 for (let i = 0; i < s.length; i++) {
1387 const p = s[i];
1388 result[p] = s.getPropertyValue(p);
1389 }
1390 return result;
1391 }"#
1392 .to_string(),
1393 };
1394
1395 let result: EvaluateResult = client
1396 .send_command_typed(
1397 "Runtime.callFunctionOn",
1398 &CallFunctionOnParams {
1399 function_declaration: js,
1400 object_id: Some(object_id),
1401 arguments: None,
1402 return_by_value: Some(true),
1403 await_promise: Some(false),
1404 },
1405 Some(&effective_session_id),
1406 )
1407 .await?;
1408
1409 Ok(result.result.value.unwrap_or(Value::Null))
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414 use super::*;
1415
1416 #[test]
1417 fn test_parse_ref_at_prefix() {
1418 assert_eq!(parse_ref("@e1"), Some("e1".to_string()));
1419 assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
1420 }
1421
1422 #[test]
1423 fn test_parse_ref_equals_prefix() {
1424 assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
1425 }
1426
1427 #[test]
1428 fn test_parse_ref_bare() {
1429 assert_eq!(parse_ref("e1"), Some("e1".to_string()));
1430 assert_eq!(parse_ref("e42"), Some("e42".to_string()));
1431 }
1432
1433 #[test]
1434 fn test_parse_ref_invalid() {
1435 assert_eq!(parse_ref("button"), None);
1436 assert_eq!(parse_ref("e"), None);
1437 assert_eq!(parse_ref("1"), None);
1438 assert_eq!(parse_ref(""), None);
1439 }
1440
1441 #[test]
1442 fn test_ref_map_basic() {
1443 let mut map = RefMap::new();
1444 map.add("e1".to_string(), Some(42), "button", "Submit", None);
1445 assert!(map.get("e1").is_some());
1446 assert_eq!(map.get("e1").unwrap().role, "button");
1447 assert!(map.get("e2").is_none());
1448 }
1449
1450 #[test]
1451 fn test_normalize_css_prefix_strips_playwright_style() {
1452 assert_eq!(normalize_css_selector("css=h1"), "h1");
1453 assert_eq!(normalize_css_selector("h1"), "h1");
1454 assert_eq!(normalize_css_selector("css=.foo > bar"), ".foo > bar");
1455 }
1456
1457 #[test]
1458 fn test_build_find_element_js_strips_css_prefix() {
1459 let js = build_find_element_js("css=h1");
1460 assert!(js.contains("document.querySelector(\"h1\")"), "got {js}");
1461 assert!(!js.contains("css=h1"), "got {js}");
1462 }
1463
1464 #[test]
1465 fn test_object_id_from_evaluate_rejects_exception() {
1466 let result = EvaluateResult {
1467 result: RemoteObject {
1468 object_type: "object".into(),
1469 subtype: Some("error".into()),
1470 value: None,
1471 description: Some("SyntaxError".into()),
1472 object_id: Some("err.1".into()),
1473 class_name: Some("SyntaxError".into()),
1474 unserializable_value: None,
1475 preview: None,
1476 },
1477 exception_details: Some(ExceptionDetails {
1478 text: "Uncaught".into(),
1479 exception: Some(RemoteObject {
1480 object_type: "object".into(),
1481 subtype: Some("error".into()),
1482 value: None,
1483 description: Some("not a valid selector".into()),
1484 object_id: Some("err.1".into()),
1485 class_name: Some("SyntaxError".into()),
1486 unserializable_value: None,
1487 preview: None,
1488 }),
1489 line_number: None,
1490 column_number: None,
1491 }),
1492 };
1493 let err = object_id_from_evaluate(result, "css=h1").unwrap_err();
1494 assert!(err.contains("Element not found"), "{err}");
1495 assert!(err.contains("not a valid selector"), "{err}");
1496 }
1497
1498 #[test]
1499 fn test_build_selector_js_css() {
1500 let js = build_selector_js("#submit-btn");
1501 assert!(js.contains("document.querySelector(\"#submit-btn\")"));
1502 assert!(!js.contains("document.evaluate"));
1503 }
1504
1505 #[test]
1506 fn test_build_selector_js_xpath() {
1507 let js = build_selector_js("xpath=//button[@id='ok']");
1508 assert!(js.contains("document.evaluate(\"//button[@id='ok']\", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)"));
1509 assert!(!js.contains("document.querySelector"));
1510 }
1511
1512 #[test]
1513 fn test_build_selector_js_xpath_empty() {
1514 let js = build_selector_js("xpath=");
1515 assert!(js.contains("document.evaluate"));
1516 }
1517
1518 #[test]
1519 fn test_build_selector_js_not_xpath_prefix() {
1520 let js = build_selector_js("xpath//div");
1522 assert!(js.contains("document.querySelector"));
1523 }
1524
1525 #[test]
1526 fn test_build_count_elements_js_css() {
1527 let js = build_count_elements_js(".item");
1528 assert!(js.contains("document.querySelectorAll(\".item\").length"));
1529 assert!(!js.contains("document.evaluate"));
1530 }
1531
1532 #[test]
1533 fn test_build_count_elements_js_xpath() {
1534 let js = build_count_elements_js("xpath=//li");
1535 assert!(js.contains("document.evaluate(\"//li\", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength"));
1536 assert!(!js.contains("querySelectorAll"));
1537 }
1538
1539 #[test]
1540 fn test_box_model_center() {
1541 let model = BoxModel {
1542 content: vec![10.0, 20.0, 110.0, 20.0, 110.0, 60.0, 10.0, 60.0],
1543 padding: vec![],
1544 border: vec![],
1545 margin: vec![],
1546 width: 100,
1547 height: 40,
1548 };
1549 let (x, y) = box_model_center(&model);
1550 assert!((x - 60.0).abs() < 0.01);
1551 assert!((y - 40.0).abs() < 0.01);
1552 }
1553
1554 #[test]
1560 fn test_cross_origin_element_uses_dedicated_session() {
1561 let mut iframe_sessions = FxHashMap::default();
1562 iframe_sessions.insert(
1563 "cross-origin-frame".to_string(),
1564 "iframe-session".to_string(),
1565 );
1566
1567 let session = resolve_frame_session(
1568 Some("cross-origin-frame"),
1569 "parent-session",
1570 &iframe_sessions,
1571 );
1572
1573 assert_eq!(session, "iframe-session");
1574 }
1575
1576 #[test]
1577 fn test_same_origin_element_uses_parent_session() {
1578 let iframe_sessions = FxHashMap::default();
1579
1580 let session = resolve_frame_session(
1581 Some("same-origin-frame"),
1582 "parent-session",
1583 &iframe_sessions,
1584 );
1585
1586 assert_eq!(session, "parent-session");
1587 }
1588
1589 #[test]
1590 fn test_main_frame_element_uses_parent_session() {
1591 let iframe_sessions = FxHashMap::default();
1592
1593 let session = resolve_frame_session(None, "parent-session", &iframe_sessions);
1594
1595 assert_eq!(session, "parent-session");
1596 }
1597}