Skip to main content

browser_commander/interactions/
click_activation.rs

1//! Orthogonal click activation options.
2//!
3//! `no_auto_scroll: true` used to be translated into the engine's "force" flag,
4//! which skips actionability checks but still scrolls the element into view. The
5//! option therefore promised something the engine never delivered. These three
6//! axes are independent and each one means exactly what it says:
7//!
8//! - [`ClickActivation`]: how the click is delivered (pointer or DOM)
9//! - [`ClickScroll`]: what may happen to the scroll position
10//! - [`ClickActionability`]: whether engine pre-checks are skipped
11
12use std::fmt;
13
14use serde_json::{json, Value};
15
16use crate::core::engine::{EngineAdapter, EngineError};
17
18/// How the click is delivered to the element.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum ClickActivation {
21    /// Real pointer input at the element's click point.
22    #[default]
23    Pointer,
24    /// `HTMLElement.click()` - an untrusted event that skips pointer handlers.
25    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/// What the click is allowed to do to the scroll position.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum ClickScroll {
41    /// Let the engine scroll the element into view (default).
42    #[default]
43    Auto,
44    /// Allow scrolling, then restore the original scroll position.
45    Preserve,
46    /// Never scroll; fail if the click cannot be delivered without scrolling.
47    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/// Whether the engine's actionability pre-checks run.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum ClickActionability {
64    /// Run the engine's pre-checks.
65    #[default]
66    Normal,
67    /// Skip engine pre-checks. Does **not** disable scrolling.
68    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/// Resolved activation options.
82#[derive(Debug, Clone, Default)]
83pub struct ActivationOptions {
84    /// How the click is delivered.
85    pub activation: ClickActivation,
86    /// What may happen to the scroll position.
87    pub scroll: ClickScroll,
88    /// Whether engine pre-checks are skipped.
89    pub actionability: ClickActionability,
90    /// Deprecation notices raised while resolving.
91    pub deprecations: Vec<String>,
92}
93
94/// The deprecation notice raised for `no_auto_scroll`.
95pub 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    /// Resolve the deprecated `no_auto_scroll` flag onto the new axes.
101    ///
102    /// `no_auto_scroll` asked for "do not scroll", so it maps to
103    /// [`ClickScroll::None`] - the axis that actually delivers that - and not to
104    /// the force flag it used to be routed through.
105    #[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/// Why a click could not be dispatched.
120#[derive(Debug, thiserror::Error)]
121pub enum ClickDispatchError {
122    /// `ClickScroll::None` was requested but cannot be honored.
123    ///
124    /// Failing loudly is the point: the previous behavior scrolled anyway and
125    /// reported success, so callers who needed the viewport to stay put had no
126    /// way to find out that it had moved.
127    #[error("{message}")]
128    ScrollConstraint {
129        /// Human-readable explanation with a suggested alternative.
130        message: String,
131        /// Structured detail (geometry and hit-test result).
132        detail: Value,
133    },
134
135    /// The engine itself failed.
136    #[error(transparent)]
137    Engine(#[from] EngineError),
138}
139
140/// The click point of an element and whether it can be reached right now.
141#[derive(Debug, Clone, Default, PartialEq)]
142pub struct ClickPoint {
143    /// Viewport x coordinate of the element's centre.
144    pub x: f64,
145    /// Viewport y coordinate of the element's centre.
146    pub y: f64,
147    /// Whether the centre lies inside the viewport.
148    pub in_viewport: bool,
149    /// Whether a hit test at the centre lands on the target.
150    pub hits_target: bool,
151    /// The raw measurement, kept as evidence.
152    pub raw: Value,
153}
154
155/// A window scroll offset.
156#[derive(Debug, Clone, Copy, PartialEq)]
157pub struct ScrollPosition {
158    /// Horizontal offset.
159    pub x: f64,
160    /// Vertical offset.
161    pub y: f64,
162}
163
164/// What the dispatch actually did.
165#[derive(Debug, Clone, Default)]
166pub struct DispatchDetail {
167    /// Which activation mode delivered the click.
168    pub mode: ClickActivation,
169    /// Scroll position before the click, when it could be read.
170    pub scroll_before: Option<ScrollPosition>,
171    /// Scroll position after the click, when it could be read.
172    pub scroll_after: Option<ScrollPosition>,
173    /// The measured click point, for `ClickScroll::None`.
174    pub point: Option<ClickPoint>,
175}
176
177impl DispatchDetail {
178    /// Whether the scroll position moved across the click.
179    ///
180    /// Returns `None` when either reading failed, because "we could not tell"
181    /// is not the same answer as "it did not move".
182    #[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
242/// Measure an element's click point and whether it is reachable right now.
243///
244/// # Arguments
245///
246/// * `adapter` - The engine adapter to use
247/// * `selector` - The CSS selector for the element
248///
249/// # Returns
250///
251/// The geometry and hit-test result, or [`EngineError::ElementNotFound`].
252pub 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
277/// Read the current window scroll position.
278///
279/// Scroll position is evidence, not a precondition, so a failure to read it
280/// yields `None` rather than failing the click.
281///
282/// # Arguments
283///
284/// * `adapter` - The engine adapter to use
285///
286/// # Returns
287///
288/// The scroll offsets, or `None` when they could not be read.
289pub 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
294/// Restore a previously captured scroll position.
295///
296/// # Arguments
297///
298/// * `adapter` - The engine adapter to use
299/// * `position` - The position to restore, if one was captured
300///
301/// # Returns
302///
303/// Nothing on success.
304pub 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            // An engine with no pointer API cannot honor the constraint at all, so
348            // report it as a refused constraint rather than a generic failure.
349            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
365/// Deliver a click according to the resolved activation options.
366///
367/// # Arguments
368///
369/// * `adapter` - The engine adapter to use
370/// * `selector` - The CSS selector for the element
371/// * `options` - The resolved activation options
372///
373/// # Returns
374///
375/// What the dispatch did, or [`ClickDispatchError::ScrollConstraint`] when
376/// `ClickScroll::None` cannot be honored.
377pub 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        // Regression test for issue #89: the flag used to be routed through the
431        // engine's force option, which does not disable scrolling.
432        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}