Skip to main content

glass/browser/session/
locator.rs

1//! Element resolution with fallback chains.
2//!
3//! Resolves locator strings (ref, accessible name, role+name, text, CSS,
4//! ordinal) into specific DOM elements. Supports fallback chains of up to
5//! [`MAX_FALLBACK_SEGMENTS`] locator segments.
6
7use super::*;
8
9#[derive(Debug)]
10struct PreflightProbeError {
11    reason: TargetActionabilityReason,
12    geometry: Option<PreflightGeometry>,
13    hints: PreflightHints,
14}
15
16impl std::fmt::Display for PreflightProbeError {
17    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        write!(formatter, "preflight probe failed: {:?}", self.reason)
19    }
20}
21
22impl std::error::Error for PreflightProbeError {}
23
24/// Maximum number of locator segments in a fallback chain.
25const MAX_FALLBACK_SEGMENTS: usize = 8;
26/// Maximum UTF-8 byte length of a single locator segment.
27const MAX_SEGMENT_BYTES: usize = 1024;
28
29impl BrowserSession {
30    pub(crate) async fn resolve_element(&self, target: &str) -> BrowserResult<ResolvedElement> {
31        // Fallback chain: split on " | " (pipe with surrounding spaces)
32        if let Some(_pipe_pos) = target.find(" | ") {
33            let segments: Vec<&str> = target.split(" | ").collect();
34            if segments.len() > MAX_FALLBACK_SEGMENTS {
35                return Err(format!(
36                    "locator fallback chain exceeds max {} segments",
37                    MAX_FALLBACK_SEGMENTS
38                )
39                .into());
40            }
41            for segment in &segments {
42                if segment.len() > MAX_SEGMENT_BYTES {
43                    let preview_end = segment
44                        .char_indices()
45                        .take_while(|(index, _)| *index < 80)
46                        .map(|(index, ch)| index + ch.len_utf8())
47                        .last()
48                        .unwrap_or(0)
49                        .min(segment.len());
50                    return Err(format!(
51                        "locator segment exceeds {} bytes: {}",
52                        MAX_SEGMENT_BYTES,
53                        &segment[..preview_end]
54                    )
55                    .into());
56                }
57            }
58
59            for segment in &segments {
60                let locator = Locator::parse(segment)?;
61                match self.resolve_locator(&locator).await? {
62                    TargetResolution::Unique(element) => return Ok(element),
63                    TargetResolution::Ambiguous(candidates) => {
64                        // Stop immediately on ambiguity — never try next segment
65                        return Err(TargetError {
66                            kind: TargetErrorKind::Ambiguous,
67                            reason: None,
68                            candidates,
69                            recovery: None,
70                        }
71                        .into());
72                    }
73                    TargetResolution::NotFound => {
74                        // Continue to next segment
75                    }
76                }
77            }
78
79            // All segments exhausted without Unique match
80            Err(TargetError {
81                kind: TargetErrorKind::NotFound,
82                reason: None,
83                candidates: Vec::new(),
84                recovery: None,
85            }
86            .into())
87        } else {
88            // Single locator — no behavioral change
89            let locator = Locator::parse(target)?;
90            match self.resolve_locator(&locator).await? {
91                TargetResolution::Unique(element) => Ok(element),
92                TargetResolution::Ambiguous(candidates) => Err(TargetError {
93                    kind: TargetErrorKind::Ambiguous,
94                    reason: None,
95                    candidates,
96                    recovery: None,
97                }
98                .into()),
99                TargetResolution::NotFound => Err(TargetError {
100                    kind: TargetErrorKind::NotFound,
101                    reason: None,
102                    candidates: Vec::new(),
103                    recovery: None,
104                }
105                .into()),
106            }
107        }
108    }
109
110    pub async fn preflight(&self, target: &str) -> PreflightOutcome {
111        self.preflight_with_action(target, PreflightAction::Click)
112            .await
113    }
114
115    pub async fn preflight_with_action(
116        &self,
117        target: &str,
118        action: PreflightAction,
119    ) -> PreflightOutcome {
120        let revision = self.page_revision.load(Ordering::Relaxed);
121        let (target_id, frame_id) = self
122            .route_identity()
123            .await
124            .ok()
125            .map(|(target, frame)| (Some(target), Some(frame)))
126            .unwrap_or((None, None));
127
128        let element = match self.resolve_element(target).await {
129            Ok(element) => element,
130            Err(error) => {
131                let error_kind = error.downcast_ref::<TargetError>().map(|e| e.kind);
132                return PreflightOutcome {
133                    action,
134                    unique: false,
135                    element: None,
136                    actionable: None,
137                    actionability_reason: None,
138                    candidates: error
139                        .downcast_ref::<TargetError>()
140                        .map(|e| e.candidates.clone())
141                        .unwrap_or_default(),
142                    error_kind,
143                    revision,
144                    geometry: None,
145                    hints: PreflightHints::default(),
146                    target_id,
147                    frame_id,
148                };
149            }
150        };
151
152        // Use a distinct read-only probe. The normal action probe may scroll
153        // the target into view, which is correct before a click but violates
154        // preflight's side-effect-free contract.
155        let (actionable, actionability_reason, geometry, hints) =
156            match self.check_element_preflight(&element).await {
157                Ok((geometry, hints)) => (true, None, Some(geometry), hints),
158                Err(error) => {
159                    let target_error = error.downcast_ref::<TargetError>();
160                    let probe_error = error.downcast_ref::<PreflightProbeError>();
161                    let reason = probe_error
162                        .map(|e| e.reason)
163                        .or_else(|| target_error.and_then(|e| e.reason));
164                    let geometry = probe_error.and_then(|e| e.geometry);
165                    let hints = probe_error.map(|e| e.hints).unwrap_or_default();
166                    (false, reason, geometry, hints)
167                }
168            };
169
170        PreflightOutcome {
171            action,
172            unique: true,
173            element: Some(element),
174            actionable: Some(actionable),
175            actionability_reason,
176            candidates: Vec::new(),
177            error_kind: None,
178            revision,
179            geometry,
180            hints,
181            target_id,
182            frame_id,
183        }
184    }
185
186    async fn check_element_preflight(
187        &self,
188        element: &ResolvedElement,
189    ) -> BrowserResult<(PreflightGeometry, PreflightHints)> {
190        let backend_node_id = element
191            .backend_dom_node_id
192            .ok_or("element has no backend node id")?;
193        let object_id = self
194            .cdp
195            .resolve_node_object(None, Some(backend_node_id))
196            .await
197            .map_err(|_| PreflightProbeError {
198                reason: TargetActionabilityReason::NodeUnavailable,
199                geometry: None,
200                hints: PreflightHints::default(),
201            })?;
202        let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id);
203        let raw = self
204            .cdp
205            .call_on_object(&remote.object_id, PREFLIGHT_FUNCTION)
206            .await
207            .map_err(|_| PreflightProbeError {
208                reason: TargetActionabilityReason::NodeUnavailable,
209                geometry: None,
210                hints: PreflightHints::default(),
211            })?;
212        let value = runtime_value(&raw)?;
213        let geometry = value["geometry"].as_object().and_then(|geometry| {
214            Some(PreflightGeometry {
215                x: geometry.get("x")?.as_f64()?,
216                y: geometry.get("y")?.as_f64()?,
217                width: geometry.get("width")?.as_f64()?,
218                height: geometry.get("height")?.as_f64()?,
219            })
220        });
221        let hints = PreflightHints {
222            likely_navigation: value["hints"]["likelyNavigation"]
223                .as_bool()
224                .unwrap_or(false),
225            likely_popup: value["hints"]["likelyPopup"].as_bool().unwrap_or(false),
226            likely_form_submit: value["hints"]["likelyFormSubmit"]
227                .as_bool()
228                .unwrap_or(false),
229        };
230        if value["ok"].as_bool() != Some(true) {
231            let reason = value["reason"].as_str().unwrap_or("verification_failed");
232            return Err(PreflightProbeError {
233                reason: actionability_reason(reason),
234                geometry,
235                hints,
236            }
237            .into());
238        }
239        Ok((geometry.ok_or("preflight returned no geometry")?, hints))
240    }
241
242    pub(crate) async fn resolve_locator(
243        &self,
244        locator: &Locator,
245    ) -> BrowserResult<TargetResolution> {
246        if let Locator::Reference(target) = locator {
247            let reference = parse_revisioned_reference(target)?
248                .ok_or_else(|| format!("invalid revisioned element reference: {target}"))?;
249            let current_revision = self.page_revision.load(Ordering::Relaxed);
250            if reference.revision != current_revision {
251                return Err(TargetError {
252                    kind: TargetErrorKind::StaleReference,
253                    reason: None,
254                    candidates: Vec::new(),
255                    recovery: Some(StaleReferenceRecovery {
256                        suggestion: "reconcileReferences",
257                        from_revision: reference.revision,
258                        stale_ref: target.to_string(),
259                    }),
260                }
261                .into());
262            }
263            return Ok(TargetResolution::Unique(ResolvedElement {
264                node_id: None,
265                backend_dom_node_id: Some(reference.backend_dom_node_id),
266                label: target.to_string(),
267                reference: Some(target.to_string()),
268                role: None,
269                input_type: None,
270            }));
271        }
272
273        let revision = self.page_revision.load(Ordering::Relaxed);
274        let raw = serde_json::to_value(self.cdp.get_accessibility_tree().await?)?;
275        let roots = parse_accessibility_tree(&raw);
276        let interactive = interactive_elements(&roots, revision);
277        let matches: Vec<&InteractiveElement> = match locator {
278            Locator::AccessibleName(name) => interactive
279                .iter()
280                .filter(|element| element.name.eq_ignore_ascii_case(name))
281                .take(AMBIGUOUS_CANDIDATE_LIMIT + 1)
282                .collect(),
283            Locator::RoleAndName { role, name } => interactive
284                .iter()
285                .filter(|element| {
286                    element.role.eq_ignore_ascii_case(role)
287                        && element.name.eq_ignore_ascii_case(name)
288                })
289                .take(AMBIGUOUS_CANDIDATE_LIMIT + 1)
290                .collect(),
291            Locator::Ordinal(index) => interactive.get(index - 1).into_iter().collect(),
292            Locator::Reference(_) | Locator::Text(_) | Locator::Css(_) => Vec::new(),
293        };
294        if matches.len() == 1 {
295            let element = matches[0];
296            return Ok(TargetResolution::Unique(ResolvedElement {
297                node_id: None,
298                backend_dom_node_id: Some(element.backend_dom_node_id),
299                label: format!("{} {}", element.role, element.name),
300                reference: Some(element.reference.clone()),
301                role: Some(element.role.clone()),
302                input_type: element.input_type.clone(),
303            }));
304        }
305        if matches.len() > 1 {
306            return Ok(TargetResolution::Ambiguous(
307                matches
308                    .into_iter()
309                    .take(AMBIGUOUS_CANDIDATE_LIMIT)
310                    .map(|element| CandidateSummary {
311                        label: bounded_candidate_label(&format!(
312                            "{} {}",
313                            element.role, element.name
314                        )),
315                        reference: Some(element.reference.clone()),
316                    })
317                    .collect(),
318            ));
319        }
320
321        match locator {
322            Locator::Css(selector) => {
323                let expression = css_query_expression(selector)?;
324                let (count, nodes) = self
325                    .cdp
326                    .bounded_element_query(&expression, AMBIGUOUS_CANDIDATE_LIMIT)
327                    .await?;
328                dom_nodes_resolution(count, nodes, format!("css={selector}"), "css match")
329            }
330            Locator::Text(text) => {
331                let expression = text_query_expression(text)?;
332                let (count, nodes) = self
333                    .cdp
334                    .bounded_element_query(&expression, AMBIGUOUS_CANDIDATE_LIMIT)
335                    .await?;
336                if count > 1 {
337                    return Ok(TargetResolution::Ambiguous(
338                        (1..=count.min(AMBIGUOUS_CANDIDATE_LIMIT))
339                            .map(|index| CandidateSummary {
340                                label: format!("text match {index}"),
341                                reference: None,
342                            })
343                            .collect(),
344                    ));
345                }
346                dom_nodes_resolution(count, nodes, format!("text={text}"), "text match")
347            }
348            Locator::Reference(_)
349            | Locator::AccessibleName(_)
350            | Locator::RoleAndName { .. }
351            | Locator::Ordinal(_) => Ok(TargetResolution::NotFound),
352        }
353    }
354}