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//!
6//! Verification reports what it *observed*. An element that is still present
7//! and unchanged is not evidence that the click did anything - that is exactly
8//! the case of a button whose handler is missing or threw - so it is reported
9//! as [`ClickEffect::NotObserved`] rather than as success.
10
11use 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/// Options for click operations.
28#[derive(Debug, Clone)]
29pub struct ClickOptions {
30    /// Whether to scroll the element into view before clicking.
31    pub scroll_into_view: bool,
32    /// Scroll behavior (smooth or instant).
33    pub scroll_behavior: ScrollBehavior,
34    /// Wait time after scrolling.
35    pub wait_after_scroll: Duration,
36    /// Wait time after clicking.
37    pub wait_after_click: Duration,
38    /// Whether to verify the click operation.
39    pub verify: bool,
40    /// Timeout for the click operation.
41    pub timeout: Duration,
42    /// How the click is delivered, what it may scroll, and whether engine
43    /// pre-checks run. These three axes are independent of each other.
44    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    /// Whether this configuration forbids the page from scrolling.
63    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
89/// Capture the pre-click state of an element for verification.
90///
91/// # Arguments
92///
93/// * `adapter` - The engine adapter to use
94/// * `selector` - The CSS selector for the element
95///
96/// # Returns
97///
98/// The pre-click state of the element
99pub 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
135/// Compare one observed property, returning evidence when it changed.
136fn 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
154/// Whether any pre-click state was captured at all.
155///
156/// A default-constructed state means the probe found nothing, so there is
157/// nothing to compare against and no change can honestly be claimed.
158fn 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
168/// Verify a click operation by comparing pre and post-click states.
169///
170/// # Arguments
171///
172/// * `adapter` - The engine adapter to use
173/// * `selector` - The CSS selector for the element
174/// * `pre_click_state` - The state captured before the click
175///
176/// # Returns
177///
178/// The verification result. Note that an unchanged, still-connected element
179/// yields [`ClickEffect::NotObserved`]: presence is not proof of effect.
180pub 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            // The execution context went away. That is consistent with the
189            // click having navigated the page, but equally consistent with an
190            // unrelated navigation already in flight. Verification is simply
191            // unavailable; attributing the navigation is the caller's job.
192            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        // Detachment is a real, observable change in the document.
250        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        // We did not scroll, but the page reacted by scrolling itself. Record
295        // it so callers asserting on viewport stability can see what moved.
296        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
308/// Click an element (low-level operation).
309///
310/// # Arguments
311///
312/// * `adapter` - The engine adapter to use
313/// * `selector` - The CSS selector for the element
314/// * `options` - Click options
315///
316/// # Returns
317///
318/// The result of the click operation. The result reports what was observed:
319/// a dispatched click whose effect nothing confirmed is
320/// [`ClickStatus::Unverified`], not a success.
321pub async fn click_element(
322    adapter: &dyn EngineAdapter,
323    selector: &str,
324    options: &ClickOptions,
325) -> Result<ClickResult, EngineError> {
326    // One monotonic budget covers probing, dispatch and verification, so a
327    // click cannot quietly cost several times the timeout the caller asked for.
328    let deadline = Deadline::new(options.timeout);
329    let action_id = next_action_id();
330    let elapsed = || deadline.elapsed_ms();
331
332    // Capture pre-click state for verification. A state that could not be read
333    // in time is no state at all; the click then has nothing to compare
334    // against and says so rather than guessing.
335    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    // A navigation replaces the document the element lived in, and engines
346    // answer an element probe on the *new* document instead of failing. Reading
347    // the URL first is what lets the result tell those two cases apart.
348    let url_before_dispatch = adapter.url().await.ok();
349
350    // Dispatch is bounded too: locating a click point and reading the scroll
351    // position are engine round-trips, and an unbounded one spends the budget
352    // the caller reserved for the whole click.
353    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            // The caller asked for no scrolling and the click cannot be
381            // delivered without it. Say so instead of scrolling behind their
382            // back and reporting success.
383            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            // The document the element belonged to is gone, so there is nothing
427            // left to observe. The navigation is recorded as what it is - a
428            // correlated event - and *not* as proof that this click caused it.
429            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            // The click was delivered either way; only the observation ran out
462            // of time, and `timed_out` says that rather than blaming the click.
463            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
494/// How far verification got before something ended it.
495enum VerificationOutcome {
496    /// Verification produced a verdict.
497    Verified(ClickVerificationResult),
498    /// The page navigated away, carrying the URL it went to.
499    Navigated(String),
500    /// The budget ran out before anything could be observed.
501    TimedOut,
502}
503
504/// How often the navigation watch samples the page URL.
505const NAVIGATION_POLL_INTERVAL: Duration = Duration::from_millis(50);
506
507/// Poll the page URL until it changes, or until the budget runs out.
508async 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            // Let the verification side of the race report the timeout, so a
522            // spent budget is described once and in one vocabulary.
523            std::future::pending::<()>().await;
524        }
525    }
526}
527
528/// Verify a click, giving up as soon as the page navigates away.
529///
530/// Without the navigation watch a navigating click spends its whole budget on
531/// a probe the engine happily answers against the *new* document, and the
532/// result then reports a timeout for what was really a navigation.
533async 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
564/// Click a button or element (high-level with scrolling and waits).
565///
566/// This function handles:
567/// - Scrolling the element into view
568/// - Clicking the element
569/// - Verifying the click
570/// - Waiting for any triggered navigation
571///
572/// # Arguments
573///
574/// * `adapter` - The engine adapter to use
575/// * `selector` - The CSS selector for the element
576/// * `options` - Click options
577///
578/// # Returns
579///
580/// The result of the click operation
581pub async fn click_button(
582    adapter: &dyn EngineAdapter,
583    selector: &str,
584    options: &ClickOptions,
585) -> Result<ClickResult, EngineError> {
586    // Scrolling, clicking, verifying and the settle wait all come out of one
587    // budget; before it was shared, each started a timer of its own and a
588    // button click could cost several times the timeout the caller asked for.
589    let deadline = Deadline::new(options.timeout);
590
591    // Scroll into view if requested. `ClickScroll::None` means what it says, so
592    // it overrides the legacy `scroll_into_view` flag instead of being silently
593    // undone by it.
594    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    // Perform the click with whatever is left of the shared budget.
614    let remaining = ClickOptions {
615        timeout: deadline.remaining(),
616        ..options.clone()
617    };
618    let result = click_element(adapter, selector, &remaining).await?;
619
620    // Wait after click if specified
621    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    /// A budget far shorter than any engine's own probe timeout.
636    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        // Regression test for issue #89: the "do not scroll" request used to be
650        // mapped onto the engine's force flag, which still scrolls, and the
651        // high-level helper scrolled before the click on top of that.
652        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        // Regression test for issue #89: this used to report `verified: true`.
672        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        // Regression test for issue #89: a no-op button left the element
699        // connected and unchanged, and that was reported as success.
700        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    /// The element state a probe reports for an untouched, connected button.
725    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    /// Answer the pre-click state probe, then stall every later one.
736    ///
737    /// This is what a real engine does after a navigation: it serves the probe
738    /// against the new document, or waits out its own locator timeout, rather
739    /// than reporting that the element is gone. Only the element-state probe is
740    /// stalled, so dispatch still reaches the page.
741    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    /// Click options that verify, with a short budget and no settle wait.
757    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        // Regression test for issue #89: the probe used to run under the
769        // engine's own timeout, so a click given 150ms could wait 30 seconds
770        // and then blame the click for being slow.
771        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        // The first `url()` call is the one taken before dispatch; every later
797        // one is the navigation watch, which must see the page has moved.
798        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        // click_button scrolls, clicks, verifies and then waits; before the
835        // budget was shared each of those started a timer of its own.
836        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}