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