Skip to main content

browser_commander/interactions/
click.rs

1//! Click operations for browser automation.
2//!
3//! This module provides utilities for clicking elements with
4//! pre-click state capture and verification support.
5
6use crate::core::constants::TIMING;
7use crate::core::engine::{ClickVerificationResult, EngineAdapter, EngineError, PreClickState};
8use crate::core::navigation::is_navigation_error;
9use crate::interactions::scroll::{scroll_into_view_if_needed, ScrollBehavior, ScrollOptions};
10use std::time::Duration;
11
12/// Options for click operations.
13#[derive(Debug, Clone)]
14pub struct ClickOptions {
15    /// Whether to scroll the element into view before clicking.
16    pub scroll_into_view: bool,
17    /// Scroll behavior (smooth or instant).
18    pub scroll_behavior: ScrollBehavior,
19    /// Wait time after scrolling.
20    pub wait_after_scroll: Duration,
21    /// Wait time after clicking.
22    pub wait_after_click: Duration,
23    /// Whether to verify the click operation.
24    pub verify: bool,
25    /// Timeout for the click operation.
26    pub timeout: Duration,
27}
28
29impl Default for ClickOptions {
30    fn default() -> Self {
31        Self {
32            scroll_into_view: true,
33            scroll_behavior: ScrollBehavior::Smooth,
34            wait_after_scroll: TIMING.default_wait_after_scroll,
35            wait_after_click: Duration::from_millis(1000),
36            verify: true,
37            timeout: TIMING.default_timeout,
38        }
39    }
40}
41
42/// Result of a click operation.
43#[derive(Debug, Clone)]
44pub struct ClickResult {
45    /// Whether the click was performed.
46    pub clicked: bool,
47    /// Whether the click was verified as successful.
48    pub verified: bool,
49    /// Whether navigation was detected.
50    pub navigated: bool,
51    /// The reason for the result.
52    pub reason: String,
53}
54
55impl ClickResult {
56    /// Create a successful click result.
57    pub fn success(reason: impl Into<String>) -> Self {
58        Self {
59            clicked: true,
60            verified: true,
61            navigated: false,
62            reason: reason.into(),
63        }
64    }
65
66    /// Create a result indicating navigation occurred.
67    pub fn navigation(reason: impl Into<String>) -> Self {
68        Self {
69            clicked: false,
70            verified: true,
71            navigated: true,
72            reason: reason.into(),
73        }
74    }
75
76    /// Create a failed click result.
77    pub fn failed(reason: impl Into<String>) -> Self {
78        Self {
79            clicked: false,
80            verified: false,
81            navigated: false,
82            reason: reason.into(),
83        }
84    }
85}
86
87/// Capture the pre-click state of an element for verification.
88///
89/// # Arguments
90///
91/// * `adapter` - The engine adapter to use
92/// * `selector` - The CSS selector for the element
93///
94/// # Returns
95///
96/// The pre-click state of the element
97pub async fn capture_pre_click_state(
98    adapter: &dyn EngineAdapter,
99    selector: &str,
100) -> Result<PreClickState, EngineError> {
101    // Use JavaScript evaluation to capture element state
102    let script = format!(
103        r#"
104        (function() {{
105            const el = document.querySelector('{}');
106            if (!el) return null;
107            return {{
108                disabled: el.disabled || false,
109                ariaPressed: el.getAttribute('aria-pressed'),
110                ariaExpanded: el.getAttribute('aria-expanded'),
111                ariaSelected: el.getAttribute('aria-selected'),
112                checked: el.checked || false,
113                className: el.className || '',
114                isConnected: el.isConnected
115            }};
116        }})()
117        "#,
118        selector.replace('\'', "\\'")
119    );
120
121    let result = adapter.evaluate(&script).await?;
122
123    if result.is_null() {
124        return Ok(PreClickState::default());
125    }
126
127    Ok(PreClickState {
128        disabled: result.get("disabled").and_then(|v| v.as_bool()),
129        aria_pressed: result
130            .get("ariaPressed")
131            .and_then(|v| v.as_str())
132            .map(String::from),
133        aria_expanded: result
134            .get("ariaExpanded")
135            .and_then(|v| v.as_str())
136            .map(String::from),
137        aria_selected: result
138            .get("ariaSelected")
139            .and_then(|v| v.as_str())
140            .map(String::from),
141        checked: result.get("checked").and_then(|v| v.as_bool()),
142        class_name: result
143            .get("className")
144            .and_then(|v| v.as_str())
145            .map(String::from),
146        is_connected: result
147            .get("isConnected")
148            .and_then(|v| v.as_bool())
149            .unwrap_or(false),
150    })
151}
152
153/// Verify a click operation by comparing pre and post-click states.
154///
155/// # Arguments
156///
157/// * `adapter` - The engine adapter to use
158/// * `selector` - The CSS selector for the element
159/// * `pre_click_state` - The state captured before the click
160///
161/// # Returns
162///
163/// The verification result
164pub async fn verify_click(
165    adapter: &dyn EngineAdapter,
166    selector: &str,
167    pre_click_state: &PreClickState,
168) -> Result<ClickVerificationResult, EngineError> {
169    let post_click_state = match capture_pre_click_state(adapter, selector).await {
170        Ok(state) => state,
171        Err(e) if is_navigation_error(&e.to_string()) => {
172            return Ok(ClickVerificationResult {
173                verified: true,
174                reason: "navigation detected (expected for navigation clicks)".to_string(),
175                navigation_error: true,
176            });
177        }
178        Err(e) => return Err(e),
179    };
180
181    // Check for state changes that indicate click was processed
182    if pre_click_state.aria_pressed != post_click_state.aria_pressed {
183        return Ok(ClickVerificationResult {
184            verified: true,
185            reason: "aria-pressed changed".to_string(),
186            navigation_error: false,
187        });
188    }
189
190    if pre_click_state.aria_expanded != post_click_state.aria_expanded {
191        return Ok(ClickVerificationResult {
192            verified: true,
193            reason: "aria-expanded changed".to_string(),
194            navigation_error: false,
195        });
196    }
197
198    if pre_click_state.aria_selected != post_click_state.aria_selected {
199        return Ok(ClickVerificationResult {
200            verified: true,
201            reason: "aria-selected changed".to_string(),
202            navigation_error: false,
203        });
204    }
205
206    if pre_click_state.checked != post_click_state.checked {
207        return Ok(ClickVerificationResult {
208            verified: true,
209            reason: "checked state changed".to_string(),
210            navigation_error: false,
211        });
212    }
213
214    if pre_click_state.class_name != post_click_state.class_name {
215        return Ok(ClickVerificationResult {
216            verified: true,
217            reason: "className changed".to_string(),
218            navigation_error: false,
219        });
220    }
221
222    // If element is still connected and not disabled, assume click worked
223    if post_click_state.is_connected {
224        return Ok(ClickVerificationResult {
225            verified: true,
226            reason: "element still connected (assumed success)".to_string(),
227            navigation_error: false,
228        });
229    }
230
231    // Element was removed from DOM - likely click triggered UI change
232    Ok(ClickVerificationResult {
233        verified: true,
234        reason: "element removed from DOM (UI updated)".to_string(),
235        navigation_error: false,
236    })
237}
238
239/// Click an element (low-level operation).
240///
241/// # Arguments
242///
243/// * `adapter` - The engine adapter to use
244/// * `selector` - The CSS selector for the element
245/// * `options` - Click options
246///
247/// # Returns
248///
249/// The result of the click operation
250pub async fn click_element(
251    adapter: &dyn EngineAdapter,
252    selector: &str,
253    options: &ClickOptions,
254) -> Result<ClickResult, EngineError> {
255    // Capture pre-click state for verification
256    let pre_click_state = if options.verify {
257        capture_pre_click_state(adapter, selector).await?
258    } else {
259        PreClickState::default()
260    };
261
262    // Perform the click
263    match adapter.click(selector).await {
264        Ok(_) => {}
265        Err(e) if is_navigation_error(&e.to_string()) => {
266            return Ok(ClickResult::navigation("navigation during click"));
267        }
268        Err(e) => return Err(e),
269    }
270
271    // Verify click if requested
272    if options.verify {
273        let verification = verify_click(adapter, selector, &pre_click_state).await?;
274        Ok(ClickResult {
275            clicked: true,
276            verified: verification.verified,
277            navigated: verification.navigation_error,
278            reason: verification.reason,
279        })
280    } else {
281        Ok(ClickResult::success("click completed"))
282    }
283}
284
285/// Click a button or element (high-level with scrolling and waits).
286///
287/// This function handles:
288/// - Scrolling the element into view
289/// - Clicking the element
290/// - Verifying the click
291/// - Waiting for any triggered navigation
292///
293/// # Arguments
294///
295/// * `adapter` - The engine adapter to use
296/// * `selector` - The CSS selector for the element
297/// * `options` - Click options
298///
299/// # Returns
300///
301/// The result of the click operation
302pub async fn click_button(
303    adapter: &dyn EngineAdapter,
304    selector: &str,
305    options: &ClickOptions,
306) -> Result<ClickResult, EngineError> {
307    // Scroll into view if requested
308    if options.scroll_into_view {
309        let scroll_options = ScrollOptions {
310            behavior: options.scroll_behavior,
311            wait_after_scroll: options.wait_after_scroll,
312            ..Default::default()
313        };
314
315        match scroll_into_view_if_needed(adapter, selector, &scroll_options).await {
316            Ok(_) => {}
317            Err(e) if is_navigation_error(&e.to_string()) => {
318                return Ok(ClickResult::navigation("navigation during scroll"));
319            }
320            Err(e) => return Err(e),
321        }
322    }
323
324    // Perform the click
325    let result = click_element(adapter, selector, options).await?;
326
327    // Wait after click if specified
328    if result.clicked {
329        tokio::time::sleep(options.wait_after_click).await;
330    }
331
332    Ok(result)
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn click_options_default() {
341        let options = ClickOptions::default();
342        assert!(options.scroll_into_view);
343        assert_eq!(options.scroll_behavior, ScrollBehavior::Smooth);
344        assert!(options.verify);
345    }
346
347    #[test]
348    fn click_result_success() {
349        let result = ClickResult::success("element clicked");
350        assert!(result.clicked);
351        assert!(result.verified);
352        assert!(!result.navigated);
353        assert_eq!(result.reason, "element clicked");
354    }
355
356    #[test]
357    fn click_result_navigation() {
358        let result = ClickResult::navigation("page navigated");
359        assert!(!result.clicked);
360        assert!(result.verified);
361        assert!(result.navigated);
362    }
363
364    #[test]
365    fn click_result_failed() {
366        let result = ClickResult::failed("element not found");
367        assert!(!result.clicked);
368        assert!(!result.verified);
369        assert!(!result.navigated);
370    }
371
372    #[test]
373    fn pre_click_state_default() {
374        let state = PreClickState::default();
375        assert!(state.disabled.is_none());
376        assert!(state.aria_pressed.is_none());
377        assert!(!state.is_connected);
378    }
379}