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