1use serde_json::json;
12
13use crate::core::constants::TIMING;
14use crate::core::engine::{ClickVerificationResult, EngineAdapter, EngineError, PreClickState};
15use crate::core::navigation::is_navigation_error;
16use crate::core::readiness::{run_within_deadline, Deadline, DeadlineOutcome};
17use crate::interactions::click_activation::{
18 dispatch_click, ActivationOptions, ClickActivation, ClickDispatchError, ClickScroll,
19 DispatchDetail,
20};
21use crate::interactions::click_result::{
22 next_action_id, ClickEffect, ClickResult, ClickStatus, Evidence,
23};
24use crate::interactions::scroll::{scroll_into_view_if_needed, ScrollBehavior, ScrollOptions};
25use std::time::Duration;
26
27#[derive(Debug, Clone)]
29pub struct ClickOptions {
30 pub scroll_into_view: bool,
32 pub scroll_behavior: ScrollBehavior,
34 pub wait_after_scroll: Duration,
36 pub wait_after_click: Duration,
38 pub verify: bool,
40 pub timeout: Duration,
42 pub activation: ActivationOptions,
45}
46
47impl Default for ClickOptions {
48 fn default() -> Self {
49 Self {
50 scroll_into_view: true,
51 scroll_behavior: ScrollBehavior::Smooth,
52 wait_after_scroll: TIMING.default_wait_after_scroll,
53 wait_after_click: Duration::from_millis(1000),
54 verify: true,
55 timeout: TIMING.default_timeout,
56 activation: ActivationOptions::default(),
57 }
58 }
59}
60
61impl ClickOptions {
62 fn forbids_scrolling(&self) -> bool {
64 self.activation.scroll == ClickScroll::None
65 }
66}
67
68fn state_probe_js(selector: &str) -> String {
69 format!(
70 r#"
71 (function() {{
72 const el = document.querySelector({});
73 if (!el) return null;
74 return {{
75 disabled: el.disabled || false,
76 ariaPressed: el.getAttribute('aria-pressed'),
77 ariaExpanded: el.getAttribute('aria-expanded'),
78 ariaSelected: el.getAttribute('aria-selected'),
79 checked: el.checked || false,
80 className: el.className || '',
81 isConnected: el.isConnected
82 }};
83 }})()
84 "#,
85 serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".to_string())
86 )
87}
88
89pub async fn capture_pre_click_state(
100 adapter: &dyn EngineAdapter,
101 selector: &str,
102) -> Result<PreClickState, EngineError> {
103 let result = adapter.evaluate(&state_probe_js(selector)).await?;
104
105 if result.is_null() {
106 return Ok(PreClickState::default());
107 }
108
109 Ok(PreClickState {
110 disabled: result.get("disabled").and_then(|v| v.as_bool()),
111 aria_pressed: result
112 .get("ariaPressed")
113 .and_then(|v| v.as_str())
114 .map(String::from),
115 aria_expanded: result
116 .get("ariaExpanded")
117 .and_then(|v| v.as_str())
118 .map(String::from),
119 aria_selected: result
120 .get("ariaSelected")
121 .and_then(|v| v.as_str())
122 .map(String::from),
123 checked: result.get("checked").and_then(|v| v.as_bool()),
124 class_name: result
125 .get("className")
126 .and_then(|v| v.as_str())
127 .map(String::from),
128 is_connected: result
129 .get("isConnected")
130 .and_then(|v| v.as_bool())
131 .unwrap_or(false),
132 })
133}
134
135fn changed(
137 key: &str,
138 reason: &str,
139 before: serde_json::Value,
140 after: serde_json::Value,
141) -> Option<ClickVerificationResult> {
142 if before == after {
143 return None;
144 }
145 Some(ClickVerificationResult::confirmed(
146 reason,
147 vec![Evidence::new(
148 "element-state",
149 json!({"key": key, "before": before, "after": after}),
150 )],
151 ))
152}
153
154fn has_pre_state(state: &PreClickState) -> bool {
159 state.is_connected
160 || state.disabled.is_some()
161 || state.aria_pressed.is_some()
162 || state.aria_expanded.is_some()
163 || state.aria_selected.is_some()
164 || state.checked.is_some()
165 || state.class_name.is_some()
166}
167
168pub async fn verify_click(
181 adapter: &dyn EngineAdapter,
182 selector: &str,
183 pre_click_state: &PreClickState,
184) -> Result<ClickVerificationResult, EngineError> {
185 let post_click_state = match capture_pre_click_state(adapter, selector).await {
186 Ok(state) => state,
187 Err(e) if is_navigation_error(&e.to_string()) => {
188 return Ok(ClickVerificationResult::not_observed(
193 "verification unavailable: execution context was destroyed during verification",
194 vec![Evidence::message("verification-unavailable", e.to_string())],
195 )
196 .with_navigation_error(true));
197 }
198 Err(e) => return Err(e),
199 };
200
201 let had_pre_state = has_pre_state(pre_click_state);
202
203 if had_pre_state {
204 let comparisons = [
205 changed(
206 "ariaPressed",
207 "aria-pressed changed",
208 json!(pre_click_state.aria_pressed),
209 json!(post_click_state.aria_pressed),
210 ),
211 changed(
212 "ariaExpanded",
213 "aria-expanded changed",
214 json!(pre_click_state.aria_expanded),
215 json!(post_click_state.aria_expanded),
216 ),
217 changed(
218 "ariaSelected",
219 "aria-selected changed",
220 json!(pre_click_state.aria_selected),
221 json!(post_click_state.aria_selected),
222 ),
223 changed(
224 "checked",
225 "checked state changed",
226 json!(pre_click_state.checked),
227 json!(post_click_state.checked),
228 ),
229 changed(
230 "className",
231 "className changed",
232 json!(pre_click_state.class_name),
233 json!(post_click_state.class_name),
234 ),
235 changed(
236 "disabled",
237 "disabled state changed",
238 json!(pre_click_state.disabled),
239 json!(post_click_state.disabled),
240 ),
241 ];
242
243 if let Some(result) = comparisons.into_iter().flatten().next() {
244 return Ok(result);
245 }
246 }
247
248 if !post_click_state.is_connected {
249 return Ok(ClickVerificationResult::confirmed(
251 "element removed from DOM (UI updated)",
252 vec![Evidence::new(
253 "element-state",
254 json!({"key": "isConnected", "after": false}),
255 )],
256 ));
257 }
258
259 let reason = if had_pre_state {
260 "no observable change to the target element after the click"
261 } else {
262 "no pre-click state captured, so no change could be observed"
263 };
264
265 Ok(ClickVerificationResult::not_observed(
266 reason,
267 vec![Evidence::new(
268 "element-state",
269 json!({"unchanged": true, "hasPreState": had_pre_state}),
270 )],
271 ))
272}
273
274fn dispatch_evidence(options: &ClickOptions, dispatch: &DispatchDetail) -> Vec<Evidence> {
275 let scroll_of = |position: Option<crate::interactions::click_activation::ScrollPosition>| {
276 position.map(|p| json!({"x": p.x, "y": p.y}))
277 };
278
279 let mut evidence = vec![Evidence::new(
280 "dispatch",
281 json!({
282 "mode": dispatch.mode.to_string(),
283 "activation": options.activation.activation.to_string(),
284 "scroll": options.activation.scroll.to_string(),
285 "actionability": options.activation.actionability.to_string(),
286 "scrollBefore": scroll_of(dispatch.scroll_before),
287 "scrollAfter": scroll_of(dispatch.scroll_after),
288 "scrollChanged": dispatch.scroll_changed(),
289 "point": dispatch.point.as_ref().map(|p| p.raw.clone()),
290 }),
291 )];
292
293 if options.activation.scroll != ClickScroll::Auto && dispatch.scroll_changed() == Some(true) {
294 evidence.push(Evidence::new(
297 "page-scrolled-itself",
298 json!({
299 "before": scroll_of(dispatch.scroll_before),
300 "after": scroll_of(dispatch.scroll_after),
301 }),
302 ));
303 }
304
305 evidence
306}
307
308pub async fn click_element(
322 adapter: &dyn EngineAdapter,
323 selector: &str,
324 options: &ClickOptions,
325) -> Result<ClickResult, EngineError> {
326 let deadline = Deadline::new(options.timeout);
329 let action_id = next_action_id();
330 let elapsed = || deadline.elapsed_ms();
331
332 let pre_click_state = if options.verify {
336 run_within_deadline(&deadline, capture_pre_click_state(adapter, selector))
337 .await
338 .value()
339 .transpose()?
340 .unwrap_or_default()
341 } else {
342 PreClickState::default()
343 };
344
345 let url_before_dispatch = adapter.url().await.ok();
349
350 let dispatched = run_within_deadline(
354 &deadline,
355 dispatch_click(adapter, selector, &options.activation),
356 )
357 .await;
358
359 let DeadlineOutcome::Completed(dispatched) = dispatched else {
360 return Ok(ClickResult::new(
361 ClickStatus::TimedOut,
362 false,
363 ClickEffect::NotObserved,
364 "click budget expired before the click could be dispatched",
365 )
366 .with_evidence(vec![Evidence::new(
367 "dispatch-timeout",
368 json!({
369 "timeoutMs": options.timeout.as_millis(),
370 "elapsedMs": deadline.elapsed_ms(),
371 }),
372 )])
373 .with_elapsed_ms(elapsed())
374 .with_action_id(action_id));
375 };
376
377 let dispatch = match dispatched {
378 Ok(detail) => detail,
379 Err(ClickDispatchError::ScrollConstraint { message, detail }) => {
380 return Ok(ClickResult::new(
384 ClickStatus::Failed,
385 false,
386 ClickEffect::NotObserved,
387 message,
388 )
389 .with_evidence(vec![Evidence::new("scroll-constraint", detail)])
390 .with_elapsed_ms(elapsed())
391 .with_action_id(action_id));
392 }
393 Err(ClickDispatchError::Engine(e)) if is_navigation_error(&e.to_string()) => {
394 return Ok(ClickResult::navigation(
395 "navigation or stop interrupted the click before it could be observed",
396 )
397 .with_evidence(vec![Evidence::message("interrupted", e.to_string())])
398 .with_elapsed_ms(elapsed())
399 .with_action_id(action_id));
400 }
401 Err(ClickDispatchError::Engine(e)) => return Err(e),
402 };
403
404 let mut evidence = dispatch_evidence(options, &dispatch);
405
406 if !options.verify {
407 return Ok(
408 ClickResult::unverified("click dispatched; verification not requested")
409 .with_evidence(evidence)
410 .with_elapsed_ms(elapsed())
411 .with_action_id(action_id),
412 );
413 }
414
415 let verification = match verify_until_navigation(
416 adapter,
417 selector,
418 &pre_click_state,
419 url_before_dispatch.as_deref(),
420 &deadline,
421 )
422 .await?
423 {
424 VerificationOutcome::Verified(verification) => verification,
425 VerificationOutcome::Navigated(to) => {
426 evidence.push(Evidence::new(
430 "navigation",
431 json!({
432 "actionId": action_id,
433 "from": url_before_dispatch,
434 "to": to,
435 "correlated": true,
436 "provesClickEffect": false,
437 }),
438 ));
439
440 return Ok(ClickResult::new(
441 ClickStatus::Unverified,
442 true,
443 ClickEffect::NotObserved,
444 "the page navigated after the click, so the click effect could not be \
445 observed on the original document",
446 )
447 .with_navigated(true)
448 .with_evidence(evidence)
449 .with_elapsed_ms(elapsed())
450 .with_action_id(action_id));
451 }
452 VerificationOutcome::TimedOut => {
453 evidence.push(Evidence::new(
454 "verification-timeout",
455 json!({
456 "timeoutMs": options.timeout.as_millis(),
457 "elapsedMs": deadline.elapsed_ms(),
458 }),
459 ));
460
461 return Ok(ClickResult::new(
464 ClickStatus::TimedOut,
465 true,
466 ClickEffect::NotObserved,
467 "verification budget expired before an effect could be observed",
468 )
469 .with_evidence(evidence)
470 .with_elapsed_ms(elapsed())
471 .with_action_id(action_id));
472 }
473 };
474
475 let confirmed = verification.effect == ClickEffect::Confirmed;
476 evidence.extend(verification.evidence);
477
478 Ok(ClickResult::new(
479 if confirmed {
480 ClickStatus::Succeeded
481 } else {
482 ClickStatus::Unverified
483 },
484 true,
485 verification.effect,
486 verification.reason,
487 )
488 .with_navigated(verification.navigation_error)
489 .with_evidence(evidence)
490 .with_elapsed_ms(elapsed())
491 .with_action_id(action_id))
492}
493
494enum VerificationOutcome {
496 Verified(ClickVerificationResult),
498 Navigated(String),
500 TimedOut,
502}
503
504const NAVIGATION_POLL_INTERVAL: Duration = Duration::from_millis(50);
506
507async fn watch_for_navigation(
509 adapter: &dyn EngineAdapter,
510 url_before: &str,
511 deadline: &Deadline,
512) -> String {
513 loop {
514 deadline.sleep_at_most(NAVIGATION_POLL_INTERVAL).await;
515 if let Ok(now) = adapter.url().await {
516 if now != url_before {
517 return now;
518 }
519 }
520 if deadline.expired() {
521 std::future::pending::<()>().await;
524 }
525 }
526}
527
528async fn verify_until_navigation(
534 adapter: &dyn EngineAdapter,
535 selector: &str,
536 pre_click_state: &PreClickState,
537 url_before: Option<&str>,
538 deadline: &Deadline,
539) -> Result<VerificationOutcome, EngineError> {
540 let verifying = verify_click(adapter, selector, pre_click_state);
541
542 let Some(url_before) = url_before else {
543 return match run_within_deadline(deadline, verifying).await {
544 DeadlineOutcome::Completed(result) => Ok(VerificationOutcome::Verified(result?)),
545 DeadlineOutcome::TimedOut => Ok(VerificationOutcome::TimedOut),
546 };
547 };
548
549 let raced = async {
550 tokio::select! {
551 result = verifying => result.map(VerificationOutcome::Verified),
552 to = watch_for_navigation(adapter, url_before, deadline) => {
553 Ok(VerificationOutcome::Navigated(to))
554 }
555 }
556 };
557
558 match run_within_deadline(deadline, raced).await {
559 DeadlineOutcome::Completed(outcome) => outcome,
560 DeadlineOutcome::TimedOut => Ok(VerificationOutcome::TimedOut),
561 }
562}
563
564pub async fn click_button(
582 adapter: &dyn EngineAdapter,
583 selector: &str,
584 options: &ClickOptions,
585) -> Result<ClickResult, EngineError> {
586 let deadline = Deadline::new(options.timeout);
590
591 if options.scroll_into_view
595 && !options.forbids_scrolling()
596 && options.activation.activation != ClickActivation::Dom
597 {
598 let scroll_options = ScrollOptions {
599 behavior: options.scroll_behavior,
600 wait_after_scroll: options.wait_after_scroll,
601 ..Default::default()
602 };
603
604 match scroll_into_view_if_needed(adapter, selector, &scroll_options).await {
605 Ok(_) => {}
606 Err(e) if is_navigation_error(&e.to_string()) => {
607 return Ok(ClickResult::navigation("navigation during scroll"));
608 }
609 Err(e) => return Err(e),
610 }
611 }
612
613 let remaining = ClickOptions {
615 timeout: deadline.remaining(),
616 ..options.clone()
617 };
618 let result = click_element(adapter, selector, &remaining).await?;
619
620 if result.dispatched {
622 deadline.sleep_at_most(options.wait_after_click).await;
623 }
624
625 Ok(result)
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use crate::core::stub_engine::StubEngine;
632 use std::sync::atomic::{AtomicUsize, Ordering};
633 use std::time::Instant;
634
635 const MS_150: Duration = Duration::from_millis(150);
637
638 #[test]
639 fn click_options_default() {
640 let options = ClickOptions::default();
641 assert!(options.scroll_into_view);
642 assert_eq!(options.scroll_behavior, ScrollBehavior::Smooth);
643 assert!(options.verify);
644 assert_eq!(options.activation.scroll, ClickScroll::Auto);
645 }
646
647 #[test]
648 fn no_auto_scroll_forbids_the_pre_click_scroll() {
649 let options = ClickOptions {
653 activation: ActivationOptions::from_no_auto_scroll(true),
654 ..ClickOptions::default()
655 };
656 assert!(options.scroll_into_view);
657 assert!(options.forbids_scrolling());
658 }
659
660 #[test]
661 fn click_result_success() {
662 let result = ClickResult::success("element clicked");
663 assert!(result.clicked);
664 assert!(result.verified);
665 assert!(!result.navigated);
666 assert_eq!(result.reason, "element clicked");
667 }
668
669 #[test]
670 fn click_result_navigation() {
671 let result = ClickResult::navigation("page navigated");
673 assert!(!result.clicked);
674 assert!(!result.verified);
675 assert!(result.navigated);
676 assert_eq!(result.status, ClickStatus::Interrupted);
677 }
678
679 #[test]
680 fn click_result_failed() {
681 let result = ClickResult::failed("element not found");
682 assert!(!result.clicked);
683 assert!(!result.verified);
684 assert!(!result.navigated);
685 }
686
687 #[test]
688 fn pre_click_state_default() {
689 let state = PreClickState::default();
690 assert!(state.disabled.is_none());
691 assert!(state.aria_pressed.is_none());
692 assert!(!state.is_connected);
693 assert!(!has_pre_state(&state));
694 }
695
696 #[test]
697 fn unchanged_element_is_not_evidence_of_an_effect() {
698 assert!(changed(
701 "checked",
702 "checked state changed",
703 json!(false),
704 json!(false)
705 )
706 .is_none());
707 }
708
709 #[test]
710 fn a_changed_property_is_confirmed_with_evidence() {
711 let result = changed(
712 "checked",
713 "checked state changed",
714 json!(false),
715 json!(true),
716 )
717 .expect("a change should be reported");
718 assert!(result.verified);
719 assert_eq!(result.effect, ClickEffect::Confirmed);
720 assert_eq!(result.evidence[0].detail["before"], json!(false));
721 assert_eq!(result.evidence[0].detail["after"], json!(true));
722 }
723
724 fn unchanged_state() -> serde_json::Value {
726 json!({
727 "disabled": false,
728 "ariaPressed": "false",
729 "checked": false,
730 "className": "btn",
731 "isConnected": true,
732 })
733 }
734
735 fn stalls_after_the_first_state_probe() -> impl Fn(usize, &str) -> Duration + Send + Sync {
742 let state_probes = AtomicUsize::new(0);
743
744 move |_call, script| {
745 if !script.contains("isConnected") {
746 return Duration::ZERO;
747 }
748 if state_probes.fetch_add(1, Ordering::SeqCst) == 0 {
749 Duration::ZERO
750 } else {
751 Duration::from_secs(30)
752 }
753 }
754 }
755
756 fn verifying_within(timeout: Duration) -> ClickOptions {
758 ClickOptions {
759 verify: true,
760 timeout,
761 wait_after_click: Duration::ZERO,
762 ..ClickOptions::default()
763 }
764 }
765
766 #[tokio::test]
767 async fn reports_timed_out_when_the_target_cannot_be_read() {
768 let adapter = StubEngine::fixed("https://example.com/start")
772 .evaluating(|_| unchanged_state())
773 .evaluating_slowly(stalls_after_the_first_state_probe());
774
775 let started = Instant::now();
776 let result = click_element(&adapter, "#target", &verifying_within(MS_150))
777 .await
778 .expect("a slow probe is not an engine error");
779
780 assert!(
781 started.elapsed() < Duration::from_secs(5),
782 "the budget, not the probe, has to end the wait"
783 );
784 assert_eq!(result.status, ClickStatus::TimedOut);
785 assert!(result.dispatched);
786 assert_eq!(result.effect, ClickEffect::NotObserved);
787 assert!(!result.verified);
788 assert!(result
789 .evidence
790 .iter()
791 .any(|item| item.kind == "verification-timeout"));
792 }
793
794 #[tokio::test]
795 async fn gives_up_on_a_target_the_page_navigated_away_from() {
796 let adapter = StubEngine::scripted(|call| {
799 if call == 0 {
800 "https://example.com/start".to_string()
801 } else {
802 "https://example.com/arrived".to_string()
803 }
804 })
805 .evaluating(|_| unchanged_state())
806 .evaluating_slowly(stalls_after_the_first_state_probe());
807
808 let started = Instant::now();
809 let result = click_element(&adapter, "#go", &verifying_within(Duration::from_secs(5)))
810 .await
811 .expect("a navigation is not an engine error");
812
813 assert!(
814 started.elapsed() < Duration::from_secs(2),
815 "the navigation, not the budget, should end the wait"
816 );
817 assert_eq!(result.status, ClickStatus::Unverified);
818 assert_ne!(result.effect, ClickEffect::Confirmed);
819
820 let navigation = result
821 .evidence
822 .iter()
823 .find(|item| item.kind == "navigation")
824 .expect("the navigation has to be recorded");
825 assert_eq!(navigation.detail["provesClickEffect"], json!(false));
826 assert_eq!(
827 navigation.detail["to"],
828 json!("https://example.com/arrived")
829 );
830 }
831
832 #[tokio::test]
833 async fn keeps_a_button_click_inside_one_budget() {
834 let adapter = StubEngine::fixed("https://example.com/start")
837 .evaluating(|_| unchanged_state())
838 .evaluating_slowly(stalls_after_the_first_state_probe());
839 let options = ClickOptions {
840 wait_after_click: Duration::from_secs(10),
841 ..verifying_within(MS_150)
842 };
843
844 let started = Instant::now();
845 let result = click_button(&adapter, "#target", &options)
846 .await
847 .expect("a slow probe is not an engine error");
848
849 assert!(
850 started.elapsed() < Duration::from_secs(5),
851 "a 150ms budget must not become {:?}",
852 started.elapsed()
853 );
854 assert_eq!(result.status, ClickStatus::TimedOut);
855 }
856
857 #[test]
858 fn dispatch_evidence_records_a_page_that_scrolled_itself() {
859 use crate::interactions::click_activation::ScrollPosition;
860
861 let options = ClickOptions {
862 activation: ActivationOptions::from_no_auto_scroll(true),
863 ..ClickOptions::default()
864 };
865 let dispatch = DispatchDetail {
866 mode: ClickActivation::Pointer,
867 scroll_before: Some(ScrollPosition { x: 0.0, y: 0.0 }),
868 scroll_after: Some(ScrollPosition { x: 0.0, y: 3911.0 }),
869 point: None,
870 };
871
872 let evidence = dispatch_evidence(&options, &dispatch);
873 assert_eq!(evidence[0].detail["scrollChanged"], json!(true));
874 assert_eq!(evidence[1].kind, "page-scrolled-itself");
875 }
876}