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 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}