use serde_json::json;
use crate::core::constants::TIMING;
use crate::core::engine::{ClickVerificationResult, EngineAdapter, EngineError, PreClickState};
use crate::core::navigation::is_navigation_error;
use crate::core::readiness::{run_within_deadline, Deadline, DeadlineOutcome};
use crate::interactions::click_activation::{
dispatch_click, ActivationOptions, ClickActivation, ClickDispatchError, ClickScroll,
DispatchDetail,
};
use crate::interactions::click_result::{
next_action_id, ClickEffect, ClickResult, ClickStatus, Evidence,
};
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,
pub activation: ActivationOptions,
}
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,
activation: ActivationOptions::default(),
}
}
}
impl ClickOptions {
fn forbids_scrolling(&self) -> bool {
self.activation.scroll == ClickScroll::None
}
}
fn state_probe_js(selector: &str) -> String {
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
}};
}})()
"#,
serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".to_string())
)
}
pub async fn capture_pre_click_state(
adapter: &dyn EngineAdapter,
selector: &str,
) -> Result<PreClickState, EngineError> {
let result = adapter.evaluate(&state_probe_js(selector)).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),
})
}
fn changed(
key: &str,
reason: &str,
before: serde_json::Value,
after: serde_json::Value,
) -> Option<ClickVerificationResult> {
if before == after {
return None;
}
Some(ClickVerificationResult::confirmed(
reason,
vec![Evidence::new(
"element-state",
json!({"key": key, "before": before, "after": after}),
)],
))
}
fn has_pre_state(state: &PreClickState) -> bool {
state.is_connected
|| state.disabled.is_some()
|| state.aria_pressed.is_some()
|| state.aria_expanded.is_some()
|| state.aria_selected.is_some()
|| state.checked.is_some()
|| state.class_name.is_some()
}
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::not_observed(
"verification unavailable: execution context was destroyed during verification",
vec![Evidence::message("verification-unavailable", e.to_string())],
)
.with_navigation_error(true));
}
Err(e) => return Err(e),
};
let had_pre_state = has_pre_state(pre_click_state);
if had_pre_state {
let comparisons = [
changed(
"ariaPressed",
"aria-pressed changed",
json!(pre_click_state.aria_pressed),
json!(post_click_state.aria_pressed),
),
changed(
"ariaExpanded",
"aria-expanded changed",
json!(pre_click_state.aria_expanded),
json!(post_click_state.aria_expanded),
),
changed(
"ariaSelected",
"aria-selected changed",
json!(pre_click_state.aria_selected),
json!(post_click_state.aria_selected),
),
changed(
"checked",
"checked state changed",
json!(pre_click_state.checked),
json!(post_click_state.checked),
),
changed(
"className",
"className changed",
json!(pre_click_state.class_name),
json!(post_click_state.class_name),
),
changed(
"disabled",
"disabled state changed",
json!(pre_click_state.disabled),
json!(post_click_state.disabled),
),
];
if let Some(result) = comparisons.into_iter().flatten().next() {
return Ok(result);
}
}
if !post_click_state.is_connected {
return Ok(ClickVerificationResult::confirmed(
"element removed from DOM (UI updated)",
vec![Evidence::new(
"element-state",
json!({"key": "isConnected", "after": false}),
)],
));
}
let reason = if had_pre_state {
"no observable change to the target element after the click"
} else {
"no pre-click state captured, so no change could be observed"
};
Ok(ClickVerificationResult::not_observed(
reason,
vec![Evidence::new(
"element-state",
json!({"unchanged": true, "hasPreState": had_pre_state}),
)],
))
}
fn dispatch_evidence(options: &ClickOptions, dispatch: &DispatchDetail) -> Vec<Evidence> {
let scroll_of = |position: Option<crate::interactions::click_activation::ScrollPosition>| {
position.map(|p| json!({"x": p.x, "y": p.y}))
};
let mut evidence = vec![Evidence::new(
"dispatch",
json!({
"mode": dispatch.mode.to_string(),
"activation": options.activation.activation.to_string(),
"scroll": options.activation.scroll.to_string(),
"actionability": options.activation.actionability.to_string(),
"scrollBefore": scroll_of(dispatch.scroll_before),
"scrollAfter": scroll_of(dispatch.scroll_after),
"scrollChanged": dispatch.scroll_changed(),
"point": dispatch.point.as_ref().map(|p| p.raw.clone()),
}),
)];
if options.activation.scroll != ClickScroll::Auto && dispatch.scroll_changed() == Some(true) {
evidence.push(Evidence::new(
"page-scrolled-itself",
json!({
"before": scroll_of(dispatch.scroll_before),
"after": scroll_of(dispatch.scroll_after),
}),
));
}
evidence
}
pub async fn click_element(
adapter: &dyn EngineAdapter,
selector: &str,
options: &ClickOptions,
) -> Result<ClickResult, EngineError> {
let deadline = Deadline::new(options.timeout);
let action_id = next_action_id();
let elapsed = || deadline.elapsed_ms();
let pre_click_state = if options.verify {
run_within_deadline(&deadline, capture_pre_click_state(adapter, selector))
.await
.value()
.transpose()?
.unwrap_or_default()
} else {
PreClickState::default()
};
let url_before_dispatch = adapter.url().await.ok();
let dispatched = run_within_deadline(
&deadline,
dispatch_click(adapter, selector, &options.activation),
)
.await;
let DeadlineOutcome::Completed(dispatched) = dispatched else {
return Ok(ClickResult::new(
ClickStatus::TimedOut,
false,
ClickEffect::NotObserved,
"click budget expired before the click could be dispatched",
)
.with_evidence(vec![Evidence::new(
"dispatch-timeout",
json!({
"timeoutMs": options.timeout.as_millis(),
"elapsedMs": deadline.elapsed_ms(),
}),
)])
.with_elapsed_ms(elapsed())
.with_action_id(action_id));
};
let dispatch = match dispatched {
Ok(detail) => detail,
Err(ClickDispatchError::ScrollConstraint { message, detail }) => {
return Ok(ClickResult::new(
ClickStatus::Failed,
false,
ClickEffect::NotObserved,
message,
)
.with_evidence(vec![Evidence::new("scroll-constraint", detail)])
.with_elapsed_ms(elapsed())
.with_action_id(action_id));
}
Err(ClickDispatchError::Engine(e)) if is_navigation_error(&e.to_string()) => {
return Ok(ClickResult::navigation(
"navigation or stop interrupted the click before it could be observed",
)
.with_evidence(vec![Evidence::message("interrupted", e.to_string())])
.with_elapsed_ms(elapsed())
.with_action_id(action_id));
}
Err(ClickDispatchError::Engine(e)) => return Err(e),
};
let mut evidence = dispatch_evidence(options, &dispatch);
if !options.verify {
return Ok(
ClickResult::unverified("click dispatched; verification not requested")
.with_evidence(evidence)
.with_elapsed_ms(elapsed())
.with_action_id(action_id),
);
}
let verification = match verify_until_navigation(
adapter,
selector,
&pre_click_state,
url_before_dispatch.as_deref(),
&deadline,
)
.await?
{
VerificationOutcome::Verified(verification) => verification,
VerificationOutcome::Navigated(to) => {
evidence.push(Evidence::new(
"navigation",
json!({
"actionId": action_id,
"from": url_before_dispatch,
"to": to,
"correlated": true,
"provesClickEffect": false,
}),
));
return Ok(ClickResult::new(
ClickStatus::Unverified,
true,
ClickEffect::NotObserved,
"the page navigated after the click, so the click effect could not be \
observed on the original document",
)
.with_navigated(true)
.with_evidence(evidence)
.with_elapsed_ms(elapsed())
.with_action_id(action_id));
}
VerificationOutcome::TimedOut => {
evidence.push(Evidence::new(
"verification-timeout",
json!({
"timeoutMs": options.timeout.as_millis(),
"elapsedMs": deadline.elapsed_ms(),
}),
));
return Ok(ClickResult::new(
ClickStatus::TimedOut,
true,
ClickEffect::NotObserved,
"verification budget expired before an effect could be observed",
)
.with_evidence(evidence)
.with_elapsed_ms(elapsed())
.with_action_id(action_id));
}
};
let confirmed = verification.effect == ClickEffect::Confirmed;
evidence.extend(verification.evidence);
Ok(ClickResult::new(
if confirmed {
ClickStatus::Succeeded
} else {
ClickStatus::Unverified
},
true,
verification.effect,
verification.reason,
)
.with_navigated(verification.navigation_error)
.with_evidence(evidence)
.with_elapsed_ms(elapsed())
.with_action_id(action_id))
}
enum VerificationOutcome {
Verified(ClickVerificationResult),
Navigated(String),
TimedOut,
}
const NAVIGATION_POLL_INTERVAL: Duration = Duration::from_millis(50);
async fn watch_for_navigation(
adapter: &dyn EngineAdapter,
url_before: &str,
deadline: &Deadline,
) -> String {
loop {
deadline.sleep_at_most(NAVIGATION_POLL_INTERVAL).await;
if let Ok(now) = adapter.url().await {
if now != url_before {
return now;
}
}
if deadline.expired() {
std::future::pending::<()>().await;
}
}
}
async fn verify_until_navigation(
adapter: &dyn EngineAdapter,
selector: &str,
pre_click_state: &PreClickState,
url_before: Option<&str>,
deadline: &Deadline,
) -> Result<VerificationOutcome, EngineError> {
let verifying = verify_click(adapter, selector, pre_click_state);
let Some(url_before) = url_before else {
return match run_within_deadline(deadline, verifying).await {
DeadlineOutcome::Completed(result) => Ok(VerificationOutcome::Verified(result?)),
DeadlineOutcome::TimedOut => Ok(VerificationOutcome::TimedOut),
};
};
let raced = async {
tokio::select! {
result = verifying => result.map(VerificationOutcome::Verified),
to = watch_for_navigation(adapter, url_before, deadline) => {
Ok(VerificationOutcome::Navigated(to))
}
}
};
match run_within_deadline(deadline, raced).await {
DeadlineOutcome::Completed(outcome) => outcome,
DeadlineOutcome::TimedOut => Ok(VerificationOutcome::TimedOut),
}
}
pub async fn click_button(
adapter: &dyn EngineAdapter,
selector: &str,
options: &ClickOptions,
) -> Result<ClickResult, EngineError> {
let deadline = Deadline::new(options.timeout);
if options.scroll_into_view
&& !options.forbids_scrolling()
&& options.activation.activation != ClickActivation::Dom
{
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 remaining = ClickOptions {
timeout: deadline.remaining(),
..options.clone()
};
let result = click_element(adapter, selector, &remaining).await?;
if result.dispatched {
deadline.sleep_at_most(options.wait_after_click).await;
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::stub_engine::StubEngine;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
const MS_150: Duration = Duration::from_millis(150);
#[test]
fn click_options_default() {
let options = ClickOptions::default();
assert!(options.scroll_into_view);
assert_eq!(options.scroll_behavior, ScrollBehavior::Smooth);
assert!(options.verify);
assert_eq!(options.activation.scroll, ClickScroll::Auto);
}
#[test]
fn no_auto_scroll_forbids_the_pre_click_scroll() {
let options = ClickOptions {
activation: ActivationOptions::from_no_auto_scroll(true),
..ClickOptions::default()
};
assert!(options.scroll_into_view);
assert!(options.forbids_scrolling());
}
#[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);
assert_eq!(result.status, ClickStatus::Interrupted);
}
#[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);
assert!(!has_pre_state(&state));
}
#[test]
fn unchanged_element_is_not_evidence_of_an_effect() {
assert!(changed(
"checked",
"checked state changed",
json!(false),
json!(false)
)
.is_none());
}
#[test]
fn a_changed_property_is_confirmed_with_evidence() {
let result = changed(
"checked",
"checked state changed",
json!(false),
json!(true),
)
.expect("a change should be reported");
assert!(result.verified);
assert_eq!(result.effect, ClickEffect::Confirmed);
assert_eq!(result.evidence[0].detail["before"], json!(false));
assert_eq!(result.evidence[0].detail["after"], json!(true));
}
fn unchanged_state() -> serde_json::Value {
json!({
"disabled": false,
"ariaPressed": "false",
"checked": false,
"className": "btn",
"isConnected": true,
})
}
fn stalls_after_the_first_state_probe() -> impl Fn(usize, &str) -> Duration + Send + Sync {
let state_probes = AtomicUsize::new(0);
move |_call, script| {
if !script.contains("isConnected") {
return Duration::ZERO;
}
if state_probes.fetch_add(1, Ordering::SeqCst) == 0 {
Duration::ZERO
} else {
Duration::from_secs(30)
}
}
}
fn verifying_within(timeout: Duration) -> ClickOptions {
ClickOptions {
verify: true,
timeout,
wait_after_click: Duration::ZERO,
..ClickOptions::default()
}
}
#[tokio::test]
async fn reports_timed_out_when_the_target_cannot_be_read() {
let adapter = StubEngine::fixed("https://example.com/start")
.evaluating(|_| unchanged_state())
.evaluating_slowly(stalls_after_the_first_state_probe());
let started = Instant::now();
let result = click_element(&adapter, "#target", &verifying_within(MS_150))
.await
.expect("a slow probe is not an engine error");
assert!(
started.elapsed() < Duration::from_secs(5),
"the budget, not the probe, has to end the wait"
);
assert_eq!(result.status, ClickStatus::TimedOut);
assert!(result.dispatched);
assert_eq!(result.effect, ClickEffect::NotObserved);
assert!(!result.verified);
assert!(result
.evidence
.iter()
.any(|item| item.kind == "verification-timeout"));
}
#[tokio::test]
async fn gives_up_on_a_target_the_page_navigated_away_from() {
let adapter = StubEngine::scripted(|call| {
if call == 0 {
"https://example.com/start".to_string()
} else {
"https://example.com/arrived".to_string()
}
})
.evaluating(|_| unchanged_state())
.evaluating_slowly(stalls_after_the_first_state_probe());
let started = Instant::now();
let result = click_element(&adapter, "#go", &verifying_within(Duration::from_secs(5)))
.await
.expect("a navigation is not an engine error");
assert!(
started.elapsed() < Duration::from_secs(2),
"the navigation, not the budget, should end the wait"
);
assert_eq!(result.status, ClickStatus::Unverified);
assert_ne!(result.effect, ClickEffect::Confirmed);
let navigation = result
.evidence
.iter()
.find(|item| item.kind == "navigation")
.expect("the navigation has to be recorded");
assert_eq!(navigation.detail["provesClickEffect"], json!(false));
assert_eq!(
navigation.detail["to"],
json!("https://example.com/arrived")
);
}
#[tokio::test]
async fn keeps_a_button_click_inside_one_budget() {
let adapter = StubEngine::fixed("https://example.com/start")
.evaluating(|_| unchanged_state())
.evaluating_slowly(stalls_after_the_first_state_probe());
let options = ClickOptions {
wait_after_click: Duration::from_secs(10),
..verifying_within(MS_150)
};
let started = Instant::now();
let result = click_button(&adapter, "#target", &options)
.await
.expect("a slow probe is not an engine error");
assert!(
started.elapsed() < Duration::from_secs(5),
"a 150ms budget must not become {:?}",
started.elapsed()
);
assert_eq!(result.status, ClickStatus::TimedOut);
}
#[test]
fn dispatch_evidence_records_a_page_that_scrolled_itself() {
use crate::interactions::click_activation::ScrollPosition;
let options = ClickOptions {
activation: ActivationOptions::from_no_auto_scroll(true),
..ClickOptions::default()
};
let dispatch = DispatchDetail {
mode: ClickActivation::Pointer,
scroll_before: Some(ScrollPosition { x: 0.0, y: 0.0 }),
scroll_after: Some(ScrollPosition { x: 0.0, y: 3911.0 }),
point: None,
};
let evidence = dispatch_evidence(&options, &dispatch);
assert_eq!(evidence[0].detail["scrollChanged"], json!(true));
assert_eq!(evidence[1].kind, "page-scrolled-itself");
}
}