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 roots = parse_accessibility_tree(&self.cdp.get_accessibility_tree().await?);
275        let interactive = interactive_elements(&roots, revision);
276        let matches: Vec<&InteractiveElement> = match locator {
277            Locator::AccessibleName(name) => interactive
278                .iter()
279                .filter(|element| element.name.eq_ignore_ascii_case(name))
280                .take(AMBIGUOUS_CANDIDATE_LIMIT + 1)
281                .collect(),
282            Locator::RoleAndName { role, name } => interactive
283                .iter()
284                .filter(|element| {
285                    element.role.eq_ignore_ascii_case(role)
286                        && element.name.eq_ignore_ascii_case(name)
287                })
288                .take(AMBIGUOUS_CANDIDATE_LIMIT + 1)
289                .collect(),
290            Locator::Ordinal(index) => interactive.get(index - 1).into_iter().collect(),
291            Locator::Reference(_) | Locator::Text(_) | Locator::Css(_) => Vec::new(),
292        };
293        if matches.len() == 1 {
294            let element = matches[0];
295            return Ok(TargetResolution::Unique(ResolvedElement {
296                node_id: None,
297                backend_dom_node_id: Some(element.backend_dom_node_id),
298                label: format!("{} {}", element.role, element.name),
299                reference: Some(element.reference.clone()),
300                role: Some(element.role.clone()),
301                input_type: element.input_type.clone(),
302            }));
303        }
304        if matches.len() > 1 {
305            return Ok(TargetResolution::Ambiguous(
306                matches
307                    .into_iter()
308                    .take(AMBIGUOUS_CANDIDATE_LIMIT)
309                    .map(|element| CandidateSummary {
310                        label: bounded_candidate_label(&format!(
311                            "{} {}",
312                            element.role, element.name
313                        )),
314                        reference: Some(element.reference.clone()),
315                    })
316                    .collect(),
317            ));
318        }
319
320        match locator {
321            Locator::Css(selector) => {
322                let expression = css_query_expression(selector)?;
323                let (count, nodes) = self
324                    .cdp
325                    .bounded_element_query(&expression, AMBIGUOUS_CANDIDATE_LIMIT)
326                    .await?;
327                dom_nodes_resolution(count, nodes, format!("css={selector}"), "css match")
328            }
329            Locator::Text(text) => {
330                let expression = text_query_expression(text)?;
331                let (count, nodes) = self
332                    .cdp
333                    .bounded_element_query(&expression, AMBIGUOUS_CANDIDATE_LIMIT)
334                    .await?;
335                if count > 1 {
336                    return Ok(TargetResolution::Ambiguous(
337                        (1..=count.min(AMBIGUOUS_CANDIDATE_LIMIT))
338                            .map(|index| CandidateSummary {
339                                label: format!("text match {index}"),
340                                reference: None,
341                            })
342                            .collect(),
343                    ));
344                }
345                dom_nodes_resolution(count, nodes, format!("text={text}"), "text match")
346            }
347            Locator::Reference(_)
348            | Locator::AccessibleName(_)
349            | Locator::RoleAndName { .. }
350            | Locator::Ordinal(_) => Ok(TargetResolution::NotFound),
351        }
352    }
353}