use crate::core::constants::TIMING;
use crate::core::engine::{ClickVerificationResult, EngineAdapter, EngineError, PreClickState};
use crate::core::navigation::is_navigation_error;
use crate::interactions::scroll::{scroll_into_view_if_needed, ScrollBehavior, ScrollOptions};
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct ClickOptions {
pub scroll_into_view: bool,
pub scroll_behavior: ScrollBehavior,
pub wait_after_scroll: Duration,
pub wait_after_click: Duration,
pub verify: bool,
pub timeout: Duration,
}
impl Default for ClickOptions {
fn default() -> Self {
Self {
scroll_into_view: true,
scroll_behavior: ScrollBehavior::Smooth,
wait_after_scroll: TIMING.default_wait_after_scroll,
wait_after_click: Duration::from_millis(1000),
verify: true,
timeout: TIMING.default_timeout,
}
}
}
#[derive(Debug, Clone)]
pub struct ClickResult {
pub clicked: bool,
pub verified: bool,
pub navigated: bool,
pub reason: String,
}
impl ClickResult {
pub fn success(reason: impl Into<String>) -> Self {
Self {
clicked: true,
verified: true,
navigated: false,
reason: reason.into(),
}
}
pub fn navigation(reason: impl Into<String>) -> Self {
Self {
clicked: false,
verified: true,
navigated: true,
reason: reason.into(),
}
}
pub fn failed(reason: impl Into<String>) -> Self {
Self {
clicked: false,
verified: false,
navigated: false,
reason: reason.into(),
}
}
}
pub async fn capture_pre_click_state(
adapter: &dyn EngineAdapter,
selector: &str,
) -> Result<PreClickState, EngineError> {
let script = format!(
r#"
(function() {{
const el = document.querySelector('{}');
if (!el) return null;
return {{
disabled: el.disabled || false,
ariaPressed: el.getAttribute('aria-pressed'),
ariaExpanded: el.getAttribute('aria-expanded'),
ariaSelected: el.getAttribute('aria-selected'),
checked: el.checked || false,
className: el.className || '',
isConnected: el.isConnected
}};
}})()
"#,
selector.replace('\'', "\\'")
);
let result = adapter.evaluate(&script).await?;
if result.is_null() {
return Ok(PreClickState::default());
}
Ok(PreClickState {
disabled: result.get("disabled").and_then(|v| v.as_bool()),
aria_pressed: result
.get("ariaPressed")
.and_then(|v| v.as_str())
.map(String::from),
aria_expanded: result
.get("ariaExpanded")
.and_then(|v| v.as_str())
.map(String::from),
aria_selected: result
.get("ariaSelected")
.and_then(|v| v.as_str())
.map(String::from),
checked: result.get("checked").and_then(|v| v.as_bool()),
class_name: result
.get("className")
.and_then(|v| v.as_str())
.map(String::from),
is_connected: result
.get("isConnected")
.and_then(|v| v.as_bool())
.unwrap_or(false),
})
}
pub async fn verify_click(
adapter: &dyn EngineAdapter,
selector: &str,
pre_click_state: &PreClickState,
) -> Result<ClickVerificationResult, EngineError> {
let post_click_state = match capture_pre_click_state(adapter, selector).await {
Ok(state) => state,
Err(e) if is_navigation_error(&e.to_string()) => {
return Ok(ClickVerificationResult {
verified: true,
reason: "navigation detected (expected for navigation clicks)".to_string(),
navigation_error: true,
});
}
Err(e) => return Err(e),
};
if pre_click_state.aria_pressed != post_click_state.aria_pressed {
return Ok(ClickVerificationResult {
verified: true,
reason: "aria-pressed changed".to_string(),
navigation_error: false,
});
}
if pre_click_state.aria_expanded != post_click_state.aria_expanded {
return Ok(ClickVerificationResult {
verified: true,
reason: "aria-expanded changed".to_string(),
navigation_error: false,
});
}
if pre_click_state.aria_selected != post_click_state.aria_selected {
return Ok(ClickVerificationResult {
verified: true,
reason: "aria-selected changed".to_string(),
navigation_error: false,
});
}
if pre_click_state.checked != post_click_state.checked {
return Ok(ClickVerificationResult {
verified: true,
reason: "checked state changed".to_string(),
navigation_error: false,
});
}
if pre_click_state.class_name != post_click_state.class_name {
return Ok(ClickVerificationResult {
verified: true,
reason: "className changed".to_string(),
navigation_error: false,
});
}
if post_click_state.is_connected {
return Ok(ClickVerificationResult {
verified: true,
reason: "element still connected (assumed success)".to_string(),
navigation_error: false,
});
}
Ok(ClickVerificationResult {
verified: true,
reason: "element removed from DOM (UI updated)".to_string(),
navigation_error: false,
})
}
pub async fn click_element(
adapter: &dyn EngineAdapter,
selector: &str,
options: &ClickOptions,
) -> Result<ClickResult, EngineError> {
let pre_click_state = if options.verify {
capture_pre_click_state(adapter, selector).await?
} else {
PreClickState::default()
};
match adapter.click(selector).await {
Ok(_) => {}
Err(e) if is_navigation_error(&e.to_string()) => {
return Ok(ClickResult::navigation("navigation during click"));
}
Err(e) => return Err(e),
}
if options.verify {
let verification = verify_click(adapter, selector, &pre_click_state).await?;
Ok(ClickResult {
clicked: true,
verified: verification.verified,
navigated: verification.navigation_error,
reason: verification.reason,
})
} else {
Ok(ClickResult::success("click completed"))
}
}
pub async fn click_button(
adapter: &dyn EngineAdapter,
selector: &str,
options: &ClickOptions,
) -> Result<ClickResult, EngineError> {
if options.scroll_into_view {
let scroll_options = ScrollOptions {
behavior: options.scroll_behavior,
wait_after_scroll: options.wait_after_scroll,
..Default::default()
};
match scroll_into_view_if_needed(adapter, selector, &scroll_options).await {
Ok(_) => {}
Err(e) if is_navigation_error(&e.to_string()) => {
return Ok(ClickResult::navigation("navigation during scroll"));
}
Err(e) => return Err(e),
}
}
let result = click_element(adapter, selector, options).await?;
if result.clicked {
tokio::time::sleep(options.wait_after_click).await;
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn click_options_default() {
let options = ClickOptions::default();
assert!(options.scroll_into_view);
assert_eq!(options.scroll_behavior, ScrollBehavior::Smooth);
assert!(options.verify);
}
#[test]
fn click_result_success() {
let result = ClickResult::success("element clicked");
assert!(result.clicked);
assert!(result.verified);
assert!(!result.navigated);
assert_eq!(result.reason, "element clicked");
}
#[test]
fn click_result_navigation() {
let result = ClickResult::navigation("page navigated");
assert!(!result.clicked);
assert!(result.verified);
assert!(result.navigated);
}
#[test]
fn click_result_failed() {
let result = ClickResult::failed("element not found");
assert!(!result.clicked);
assert!(!result.verified);
assert!(!result.navigated);
}
#[test]
fn pre_click_state_default() {
let state = PreClickState::default();
assert!(state.disabled.is_none());
assert!(state.aria_pressed.is_none());
assert!(!state.is_connected);
}
}