browser_commander/interactions/
click_activation.rs1use std::fmt;
13
14use serde_json::{json, Value};
15
16use crate::core::engine::{EngineAdapter, EngineError};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum ClickActivation {
21 #[default]
23 Pointer,
24 Dom,
26}
27
28impl fmt::Display for ClickActivation {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 let text = match self {
31 Self::Pointer => "pointer",
32 Self::Dom => "dom",
33 };
34 write!(f, "{text}")
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum ClickScroll {
41 #[default]
43 Auto,
44 Preserve,
46 None,
48}
49
50impl fmt::Display for ClickScroll {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 let text = match self {
53 Self::Auto => "auto",
54 Self::Preserve => "preserve",
55 Self::None => "none",
56 };
57 write!(f, "{text}")
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum ClickActionability {
64 #[default]
66 Normal,
67 Force,
69}
70
71impl fmt::Display for ClickActionability {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 let text = match self {
74 Self::Normal => "normal",
75 Self::Force => "force",
76 };
77 write!(f, "{text}")
78 }
79}
80
81#[derive(Debug, Clone, Default)]
83pub struct ActivationOptions {
84 pub activation: ClickActivation,
86 pub scroll: ClickScroll,
88 pub actionability: ClickActionability,
90 pub deprecations: Vec<String>,
92}
93
94pub const NO_AUTO_SCROLL_DEPRECATION: &str =
96 "no_auto_scroll is deprecated; use scroll = ClickScroll::None (no scrolling at all) \
97 or actionability = ClickActionability::Force (skip engine pre-checks, scrolling still allowed)";
98
99impl ActivationOptions {
100 #[must_use]
106 pub fn from_no_auto_scroll(no_auto_scroll: bool) -> Self {
107 Self {
108 scroll: if no_auto_scroll {
109 ClickScroll::None
110 } else {
111 ClickScroll::Auto
112 },
113 deprecations: vec![NO_AUTO_SCROLL_DEPRECATION.to_string()],
114 ..Self::default()
115 }
116 }
117}
118
119#[derive(Debug, thiserror::Error)]
121pub enum ClickDispatchError {
122 #[error("{message}")]
128 ScrollConstraint {
129 message: String,
131 detail: Value,
133 },
134
135 #[error(transparent)]
137 Engine(#[from] EngineError),
138}
139
140#[derive(Debug, Clone, Default, PartialEq)]
142pub struct ClickPoint {
143 pub x: f64,
145 pub y: f64,
147 pub in_viewport: bool,
149 pub hits_target: bool,
151 pub raw: Value,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq)]
157pub struct ScrollPosition {
158 pub x: f64,
160 pub y: f64,
162}
163
164#[derive(Debug, Clone, Default)]
166pub struct DispatchDetail {
167 pub mode: ClickActivation,
169 pub scroll_before: Option<ScrollPosition>,
171 pub scroll_after: Option<ScrollPosition>,
173 pub point: Option<ClickPoint>,
175}
176
177impl DispatchDetail {
178 #[must_use]
183 pub fn scroll_changed(&self) -> Option<bool> {
184 match (self.scroll_before, self.scroll_after) {
185 (Some(before), Some(after)) => Some(before.x != after.x || before.y != after.y),
186 _ => None,
187 }
188 }
189}
190
191const READ_SCROLL_JS: &str = "(() => ({x: window.scrollX, y: window.scrollY}))()";
192
193fn click_point_js(selector: &str) -> String {
194 format!(
195 r#"(() => {{
196 const el = document.querySelector({});
197 if (!el) return null;
198 const rect = el.getBoundingClientRect();
199 const x = rect.left + rect.width / 2;
200 const y = rect.top + rect.height / 2;
201 const inViewport = rect.width > 0 && rect.height > 0 &&
202 x >= 0 && y >= 0 &&
203 x <= window.innerWidth && y <= window.innerHeight;
204 const hit = inViewport ? document.elementFromPoint(x, y) : null;
205 return {{
206 x, y,
207 width: rect.width, height: rect.height,
208 top: rect.top, left: rect.left,
209 viewport: {{width: window.innerWidth, height: window.innerHeight}},
210 scroll: {{x: window.scrollX, y: window.scrollY}},
211 inViewport,
212 hitsTarget: Boolean(hit && (hit === el || el.contains(hit))),
213 }};
214 }})()"#,
215 json_selector(selector)
216 )
217}
218
219fn dom_click_js(selector: &str) -> String {
220 format!(
221 "(() => {{ const el = document.querySelector({}); \
222 if (!el) return false; el.click(); return true; }})()",
223 json_selector(selector)
224 )
225}
226
227fn restore_scroll_js(position: ScrollPosition) -> String {
228 format!("(() => window.scrollTo({}, {}))()", position.x, position.y)
229}
230
231fn json_selector(selector: &str) -> String {
232 serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".to_string())
233}
234
235fn scroll_from_value(value: &Value) -> Option<ScrollPosition> {
236 Some(ScrollPosition {
237 x: value.get("x")?.as_f64()?,
238 y: value.get("y")?.as_f64()?,
239 })
240}
241
242pub async fn measure_click_point(
253 adapter: &dyn EngineAdapter,
254 selector: &str,
255) -> Result<ClickPoint, EngineError> {
256 let value = adapter.evaluate(&click_point_js(selector)).await?;
257
258 if value.is_null() {
259 return Err(EngineError::ElementNotFound(selector.to_string()));
260 }
261
262 Ok(ClickPoint {
263 x: value.get("x").and_then(Value::as_f64).unwrap_or(0.0),
264 y: value.get("y").and_then(Value::as_f64).unwrap_or(0.0),
265 in_viewport: value
266 .get("inViewport")
267 .and_then(Value::as_bool)
268 .unwrap_or(false),
269 hits_target: value
270 .get("hitsTarget")
271 .and_then(Value::as_bool)
272 .unwrap_or(false),
273 raw: value,
274 })
275}
276
277pub async fn read_scroll_position(adapter: &dyn EngineAdapter) -> Option<ScrollPosition> {
290 let value = adapter.evaluate(READ_SCROLL_JS).await.ok()?;
291 scroll_from_value(&value)
292}
293
294pub async fn restore_scroll_position(
305 adapter: &dyn EngineAdapter,
306 position: Option<ScrollPosition>,
307) -> Result<(), EngineError> {
308 let Some(position) = position else {
309 return Ok(());
310 };
311 adapter.evaluate(&restore_scroll_js(position)).await?;
312 Ok(())
313}
314
315async fn dispatch_pointer_without_scrolling(
316 adapter: &dyn EngineAdapter,
317 selector: &str,
318) -> Result<DispatchDetail, ClickDispatchError> {
319 let point = measure_click_point(adapter, selector).await?;
320
321 if !point.in_viewport {
322 return Err(ClickDispatchError::ScrollConstraint {
323 message: "scroll = ClickScroll::None was requested but the element is outside \
324 the viewport, so a real pointer click cannot reach it without scrolling. \
325 Use ClickScroll::Preserve to scroll and restore, ClickScroll::Auto to \
326 allow scrolling, or ClickActivation::Dom to dispatch an untrusted click."
327 .to_string(),
328 detail: point.raw,
329 });
330 }
331
332 if !point.hits_target {
333 return Err(ClickDispatchError::ScrollConstraint {
334 message: "scroll = ClickScroll::None was requested but another element covers \
335 the target at its click point, so a real pointer click would hit the \
336 wrong element."
337 .to_string(),
338 detail: point.raw,
339 });
340 }
341
342 let scroll_before = read_scroll_position(adapter).await;
343 adapter
344 .mouse_click(point.x, point.y)
345 .await
346 .map_err(|error| {
347 ClickDispatchError::ScrollConstraint {
350 message: format!(
351 "scroll = ClickScroll::None needs viewport-coordinate pointer input: {error}"
352 ),
353 detail: json!({ "reason": "engine has no pointer API" }),
354 }
355 })?;
356
357 Ok(DispatchDetail {
358 mode: ClickActivation::Pointer,
359 scroll_before,
360 scroll_after: read_scroll_position(adapter).await,
361 point: Some(point),
362 })
363}
364
365pub async fn dispatch_click(
378 adapter: &dyn EngineAdapter,
379 selector: &str,
380 options: &ActivationOptions,
381) -> Result<DispatchDetail, ClickDispatchError> {
382 if options.activation == ClickActivation::Dom {
383 let scroll_before = read_scroll_position(adapter).await;
384 let clicked = adapter.evaluate(&dom_click_js(selector)).await?;
385 if clicked == Value::Bool(false) {
386 return Err(EngineError::ElementNotFound(selector.to_string()).into());
387 }
388 return Ok(DispatchDetail {
389 mode: ClickActivation::Dom,
390 scroll_before,
391 scroll_after: read_scroll_position(adapter).await,
392 point: None,
393 });
394 }
395
396 if options.scroll == ClickScroll::None {
397 return dispatch_pointer_without_scrolling(adapter, selector).await;
398 }
399
400 let scroll_before = read_scroll_position(adapter).await;
401 adapter.click(selector).await?;
402
403 if options.scroll == ClickScroll::Preserve {
404 restore_scroll_position(adapter, scroll_before).await?;
405 }
406
407 Ok(DispatchDetail {
408 mode: ClickActivation::Pointer,
409 scroll_before,
410 scroll_after: read_scroll_position(adapter).await,
411 point: None,
412 })
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn defaults_scroll_and_never_force() {
421 let options = ActivationOptions::default();
422 assert_eq!(options.activation, ClickActivation::Pointer);
423 assert_eq!(options.scroll, ClickScroll::Auto);
424 assert_eq!(options.actionability, ClickActionability::Normal);
425 assert!(options.deprecations.is_empty());
426 }
427
428 #[test]
429 fn no_auto_scroll_maps_to_scroll_none_not_to_force() {
430 let options = ActivationOptions::from_no_auto_scroll(true);
433 assert_eq!(options.scroll, ClickScroll::None);
434 assert_eq!(options.actionability, ClickActionability::Normal);
435 assert_eq!(options.deprecations, vec![NO_AUTO_SCROLL_DEPRECATION]);
436 }
437
438 #[test]
439 fn no_auto_scroll_false_allows_scrolling() {
440 let options = ActivationOptions::from_no_auto_scroll(false);
441 assert_eq!(options.scroll, ClickScroll::Auto);
442 }
443
444 #[test]
445 fn axes_render_the_documented_names() {
446 assert_eq!(ClickActivation::Dom.to_string(), "dom");
447 assert_eq!(ClickScroll::Preserve.to_string(), "preserve");
448 assert_eq!(ClickActionability::Force.to_string(), "force");
449 }
450
451 #[test]
452 fn scroll_changed_reports_unknown_when_a_reading_is_missing() {
453 let mut detail = DispatchDetail {
454 scroll_before: Some(ScrollPosition { x: 0.0, y: 0.0 }),
455 ..DispatchDetail::default()
456 };
457 assert_eq!(detail.scroll_changed(), None);
458
459 detail.scroll_after = Some(ScrollPosition { x: 0.0, y: 0.0 });
460 assert_eq!(detail.scroll_changed(), Some(false));
461
462 detail.scroll_after = Some(ScrollPosition { x: 0.0, y: 3911.0 });
463 assert_eq!(detail.scroll_changed(), Some(true));
464 }
465
466 #[test]
467 fn selectors_are_json_escaped_into_the_probe() {
468 let script = click_point_js("button[data-id='a\"b']");
469 assert!(script.contains(r#"document.querySelector("button[data-id='a\"b']")"#));
470 }
471}