Skip to main content

browser_automation_cli/native/
element.rs

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
156/// Mirror of DaemonState.active_frame_id, refreshed before every command
157/// (commands are serialized by the session state lock, so this cannot
158/// race). It lets CSS-selector resolution honor `frame <sel>` without
159/// threading a parameter through every interaction signature; snapshot refs
160/// already carry their frame through the ref map.
161static 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
182/// Object handle for the <iframe> element that owns a frame, resolved on the
183/// parent session. Works for same-process frames where no dedicated CDP
184/// session exists.
185pub(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
218/// Find a selector inside a same-process iframe and return its center in
219/// top-level viewport coordinates (input events dispatch in that space).
220/// Same-origin access to contentDocument is what makes this possible; a
221/// cross-origin frame never takes this path because it has its own session.
222async 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
282/// Find a selector inside a same-process iframe and return its object handle.
283async 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        // Try cached backend_node_id first (fast path)
329        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            // backend_node_id is stale; re-query the accessibility tree below
357        }
358
359        // Fallback: re-query the accessibility tree to find a fresh node by role/name
360        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    // CSS selector: honor an active `frame <sel>` selection.
396    if let Some(frame_id) = active_frame() {
397        // Cross-process iframe: its dedicated session's main frame IS the
398        // iframe, so plain document-rooted resolution works there.
399        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
412/// Hit-test a ref-resolved node at its computed click point and error if an
413/// unrelated element (overlay, banner, sticky header) would receive the input
414/// instead. Best effort: resolution failures skip the check rather than block
415/// the interaction.
416async 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    // Box-model coordinates are in the top-level viewport space, so the
442    // hit-test starts from the top document. For an OOPIF node the
443    // frameElement walk stops at the process boundary, where the frame's own
444    // document and session-local coordinates are already consistent.
445    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
479/// Coordinates from DOM.getBoxModel are viewport-relative, and input events
480/// only land inside the viewport, so make sure the node is visible first.
481/// Best effort: a node that cannot be scrolled (display:none, detached) will
482/// fail in DOM.getBoxModel with a clearer error anyway.
483async 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        // Try cached backend_node_id first (fast path)
509        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            // backend_node_id is stale; re-query the accessibility tree below
528        }
529
530        // Fallback: re-query the accessibility tree to find a fresh node by role/name
531        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    // Selector fallback (CSS or XPath): honor an active `frame <sel>` selection.
560    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
600/// Determine which CDP session and parameters to use for an AX tree query.
601/// Cross-origin iframes have a dedicated session (no frameId needed);
602/// same-origin iframes use the parent session with a frameId parameter.
603pub(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
619/// Resolve the effective CDP session for an element's frame.
620/// If the element's frame_id has a dedicated cross-origin iframe session, return it.
621/// Otherwise, return the parent session.
622fn 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
633/// Re-query the accessibility tree to find a node matching role+name+nth,
634/// returning its fresh backendDOMNodeId. This uses the same data source
635/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
636/// so role/name matching is guaranteed to be consistent.
637async 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
696/// Strip Playwright-style `css=` so querySelector receives a valid selector.
697fn 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
704/// Build a JS expression that finds a DOM element by CSS selector or XPath.
705fn build_find_element_js(selector: &str) -> String {
706    build_find_element_js_in("document", selector)
707}
708
709/// Same as build_find_element_js but rooted at an arbitrary Document
710/// expression (e.g. an iframe's contentDocument).
711fn 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
726/// Build a JS expression that counts matching DOM elements by CSS selector or XPath.
727fn 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
742/// Require a DOM objectId from Runtime.evaluate; treat thrown selectors as not found.
743fn 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
759/// JS function source for `blockerAt(doc, el, x, y)`: returns a short
760/// description of the element that would actually receive a click at (x, y)
761/// when that element is unrelated to `el`, or null when the click would land
762/// on `el` (or something that activates it). Relations that count as "lands
763/// on el": shadow-including ancestors/descendants in either direction, and
764/// label/control association (custom checkboxes hide the input under a styled
765/// sibling inside the same label).
766const 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    // Input events dispatch at viewport coordinates, so an element outside the
801    // viewport must be scrolled into view first or the click lands on nothing.
802    // The blocker check reports an overlay covering the click point instead of
803    // letting the input land on it and silently doing the wrong thing.
804    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    // content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
876    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    // Prefer HTML attribute; fall back to DOM property (innerText, value, checked, …).
942    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    // Mirrors Playwright's getChecked() with follow-label retargeting:
1066    // 1. If element is a native checkbox/radio input, return .checked
1067    // 2. If element has an ARIA checked role, return aria-checked
1068    // 3. Follow label → input association (label.control)
1069    // 4. Check for nested checkbox/radio input as last resort
1070    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        // "xpath" without "=" should be treated as CSS selector
1502        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    // -----------------------------------------------------------------------
1536    // resolve_frame_session tests (Issue #925)
1537    // Cross-origin iframe elements must resolve to the dedicated session.
1538    // -----------------------------------------------------------------------
1539
1540    #[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}