1use 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
24const MAX_FALLBACK_SEGMENTS: usize = 8;
26const MAX_SEGMENT_BYTES: usize = 1024;
28
29impl BrowserSession {
30 pub(crate) async fn resolve_element(&self, target: &str) -> BrowserResult<ResolvedElement> {
31 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 return Err(TargetError {
66 kind: TargetErrorKind::Ambiguous,
67 reason: None,
68 candidates,
69 recovery: None,
70 }
71 .into());
72 }
73 TargetResolution::NotFound => {
74 }
76 }
77 }
78
79 Err(TargetError {
81 kind: TargetErrorKind::NotFound,
82 reason: None,
83 candidates: Vec::new(),
84 recovery: None,
85 }
86 .into())
87 } else {
88 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 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}