Skip to main content

browser_automation_cli/native/
element.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Element resolution, hit-testing, and ref-based node helpers.
3//!
4//! # Workload
5//!
6//! **I/O-bound single-act:** each resolve is one CDP call (or a short chain) on
7//! the active session. AX walks are sequential over typically small trees
8//! (cost ≪ Rayon; N-138). Multi-ref fan-out lives in snapshot/grab, not here.
9#![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
26/// Ref id → entry map for the current snapshot.
27///
28/// Uses [`FxHashMap`]: ref ids are short process-minted tokens (`e1`, `e2`, …),
29/// not untrusted external keys (rules_rust_eficiencia_e_performance).
30pub 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
167/// Mirror of session active frame id, refreshed before every command
168/// (commands are serialized by the session state lock, so this cannot
169/// race). It lets CSS-selector resolution honor `frame <sel>` without
170/// threading a parameter through every interaction signature; snapshot refs
171/// already carry their frame through the ref map.
172///
173/// # Interior mutability choice
174///
175/// - Needs a process-wide `Sync` static with a non-`Copy` payload (`String`) →
176///   `std::sync::Mutex` (not `Cell`/`RefCell`/`Atomic*`).
177/// - Not `tokio::sync::Mutex`: accessors are sync and never hold the guard
178///   across `.await`.
179/// - Direct `Mutex::new` (MSRV ≥ 1.63) — no `OnceLock`/`LazyLock` wrapper.
180/// - Poison is recovered via `into_inner` so a prior panic cannot sticky-fail
181///   frame ops.
182static 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
201/// Object handle for the <iframe> element that owns a frame, resolved on the
202/// parent session. Works for same-process frames where no dedicated CDP
203/// session exists.
204pub(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
237/// Find a selector inside a same-process iframe and return its center in
238/// top-level viewport coordinates (input events dispatch in that space).
239/// Same-origin access to contentDocument is what makes this possible; a
240/// cross-origin frame never takes this path because it has its own session.
241async 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
301/// Find a selector inside a same-process iframe and return its object handle.
302async 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        // Try cached backend_node_id first (fast path)
348        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            // backend_node_id is stale; re-query the accessibility tree below
376        }
377
378        // Fallback: re-query the accessibility tree to find a fresh node by role/name
379        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    // CSS selector: honor an active `frame <sel>` selection.
415    if let Some(frame_id) = active_frame() {
416        // Cross-process iframe: its dedicated session's main frame IS the
417        // iframe, so plain document-rooted resolution works there.
418        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
431/// Hit-test a ref-resolved node at its computed click point and error if an
432/// unrelated element (overlay, banner, sticky header) would receive the input
433/// instead. Best effort: resolution failures skip the check rather than block
434/// the interaction.
435async 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    // Box-model coordinates are in the top-level viewport space, so the
461    // hit-test starts from the top document. For an OOPIF node the
462    // frameElement walk stops at the process boundary, where the frame's own
463    // document and session-local coordinates are already consistent.
464    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
498/// Coordinates from DOM.getBoxModel are viewport-relative, and input events
499/// only land inside the viewport, so make sure the node is visible first.
500/// Best effort: a node that cannot be scrolled (display:none, detached) will
501/// fail in DOM.getBoxModel with a clearer error anyway.
502async 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        // Try cached backend_node_id first (fast path)
528        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            // backend_node_id is stale; re-query the accessibility tree below
547        }
548
549        // Fallback: re-query the accessibility tree to find a fresh node by role/name
550        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    // Selector fallback (CSS or XPath): honor an active `frame <sel>` selection.
579    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
619/// Determine which CDP session and parameters to use for an AX tree query.
620/// Cross-origin iframes have a dedicated session (no frameId needed);
621/// same-origin iframes use the parent session with a frameId parameter.
622pub(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
638/// Resolve the effective CDP session for an element's frame.
639/// If the element's frame_id has a dedicated cross-origin iframe session, return it.
640/// Otherwise, return the parent session.
641fn 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
652/// Re-query the accessibility tree to find a node matching role+name+nth,
653/// returning its fresh backendDOMNodeId. This uses the same data source
654/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
655/// so role/name matching is guaranteed to be consistent.
656async 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
715/// Strip Playwright-style `css=` so querySelector receives a valid selector.
716fn 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
723/// Build a JS expression that finds a DOM element by CSS selector or XPath.
724fn build_find_element_js(selector: &str) -> String {
725    build_find_element_js_in("document", selector)
726}
727
728/// Same as build_find_element_js but rooted at an arbitrary Document
729/// expression (e.g. an iframe's contentDocument).
730fn 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
745/// Build a JS expression that counts matching DOM elements by CSS selector or XPath.
746fn 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
761/// Require a DOM objectId from Runtime.evaluate; treat thrown selectors as not found.
762fn 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
778/// JS function source for `blockerAt(doc, el, x, y)`: returns a short
779/// description of the element that would actually receive a click at (x, y)
780/// when that element is unrelated to `el`, or null when the click would land
781/// on `el` (or something that activates it). Relations that count as "lands
782/// on el": shadow-including ancestors/descendants in either direction, and
783/// label/control association (custom checkboxes hide the input under a styled
784/// sibling inside the same label).
785const 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    // Input events dispatch at viewport coordinates, so an element outside the
820    // viewport must be scrolled into view first or the click lands on nothing.
821    // The blocker check reports an overlay covering the click point instead of
822    // letting the input land on it and silently doing the wrong thing.
823    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    // content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
895    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    // Prefer HTML attribute; fall back to DOM property (innerText, value, checked, …).
961    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    // Mirrors Playwright's getChecked() with follow-label retargeting:
1085    // 1. If element is a native checkbox/radio input, return .checked
1086    // 2. If element has an ARIA checked role, return aria-checked
1087    // 3. Follow label → input association (label.control)
1088    // 4. Check for nested checkbox/radio input as last resort
1089    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        // "xpath" without "=" should be treated as CSS selector
1521        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    // -----------------------------------------------------------------------
1555    // resolve_frame_session tests (Issue #925)
1556    // Cross-origin iframe elements must resolve to the dedicated session.
1557    // -----------------------------------------------------------------------
1558
1559    #[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}