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