Skip to main content

browser_commander/browser/
navigation_ops.rs

1//! Navigation operations for browser automation.
2//!
3//! This module provides high-level navigation utilities with
4//! verification and stabilization support.
5
6use crate::core::constants::TIMING;
7use crate::core::engine::{EngineAdapter, EngineError};
8use crate::core::navigation::is_navigation_error;
9use crate::core::readiness::{CheckRecord, Deadline, ReadinessOutcome};
10use serde_json::json;
11use std::time::{Duration, Instant};
12
13/// Options for navigation operations.
14#[derive(Debug, Clone)]
15pub struct NavigationOptions {
16    /// Wait until condition for navigation.
17    pub wait_until: WaitUntil,
18    /// Navigation timeout.
19    pub timeout: Duration,
20    /// Whether to wait for URL to stabilize before navigation.
21    pub wait_for_stable_url_before: bool,
22    /// Whether to wait for URL to stabilize after navigation.
23    pub wait_for_stable_url_after: bool,
24    /// Whether to verify the navigation.
25    pub verify: bool,
26    /// Verification timeout.
27    pub verification_timeout: Duration,
28    /// Number of consecutive stable checks required.
29    pub stable_checks: u32,
30    /// Interval between stability checks.
31    pub check_interval: Duration,
32}
33
34impl Default for NavigationOptions {
35    fn default() -> Self {
36        Self {
37            wait_until: WaitUntil::DomContentLoaded,
38            timeout: TIMING.navigation_timeout,
39            wait_for_stable_url_before: true,
40            wait_for_stable_url_after: true,
41            verify: true,
42            verification_timeout: TIMING.verification_timeout,
43            stable_checks: 3,
44            check_interval: Duration::from_secs(1),
45        }
46    }
47}
48
49/// Wait until conditions for page load.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum WaitUntil {
52    /// Wait for DOMContentLoaded event.
53    #[default]
54    DomContentLoaded,
55    /// Wait for load event.
56    Load,
57    /// Wait for network to be idle.
58    NetworkIdle,
59}
60
61impl std::fmt::Display for WaitUntil {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            WaitUntil::DomContentLoaded => write!(f, "domcontentloaded"),
65            WaitUntil::Load => write!(f, "load"),
66            WaitUntil::NetworkIdle => write!(f, "networkidle"),
67        }
68    }
69}
70
71/// Result of a navigation verification.
72#[derive(Debug, Clone)]
73pub struct NavigationVerificationResult {
74    /// Whether the navigation was verified as successful.
75    pub verified: bool,
76    /// The actual URL after navigation.
77    pub actual_url: String,
78    /// The reason for the verification result.
79    pub reason: String,
80    /// Number of verification attempts.
81    pub attempts: u32,
82}
83
84/// Result of a navigation operation.
85#[derive(Debug, Clone)]
86pub struct NavigationResult {
87    /// Whether navigation was performed.
88    pub navigated: bool,
89    /// Whether the navigation was verified as successful.
90    pub verified: bool,
91    /// The actual URL after navigation.
92    pub actual_url: Option<String>,
93    /// The reason for the result.
94    pub reason: Option<String>,
95    /// Evidence for every readiness check the navigation ran.
96    ///
97    /// `None` when the navigation ended before any check could run.
98    pub readiness: Option<ReadinessOutcome>,
99}
100
101impl NavigationResult {
102    /// Create a successful navigation result.
103    pub fn success(actual_url: String) -> Self {
104        Self {
105            navigated: true,
106            verified: true,
107            actual_url: Some(actual_url),
108            reason: Some("navigation completed".to_string()),
109            readiness: None,
110        }
111    }
112
113    /// Create a result indicating navigation was interrupted.
114    pub fn interrupted(reason: impl Into<String>) -> Self {
115        Self {
116            navigated: false,
117            verified: false,
118            actual_url: None,
119            reason: Some(reason.into()),
120            readiness: None,
121        }
122    }
123
124    /// Attach readiness evidence.
125    ///
126    /// A navigation whose readiness checks did not pass is not verified, no
127    /// matter what the URL says - the previous code discarded the verdict and
128    /// reported success regardless.
129    #[must_use]
130    pub fn with_readiness(mut self, readiness: ReadinessOutcome) -> Self {
131        if !readiness.ready {
132            self.verified = false;
133            self.reason = Some(readiness.summary());
134        }
135        self.readiness = Some(readiness);
136        self
137    }
138}
139
140/// Verify that navigation completed successfully.
141///
142/// # Arguments
143///
144/// * `adapter` - The engine adapter to use
145/// * `expected_url` - The expected URL (optional, for pattern matching)
146/// * `start_url` - The URL before navigation
147/// * `options` - Navigation options
148///
149/// # Returns
150///
151/// The verification result
152pub async fn verify_navigation(
153    adapter: &dyn EngineAdapter,
154    expected_url: Option<&str>,
155    start_url: &str,
156    options: &NavigationOptions,
157) -> Result<NavigationVerificationResult, EngineError> {
158    let deadline = Deadline::new(options.verification_timeout);
159    verify_navigation_within(adapter, expected_url, start_url, options, &deadline).await
160}
161
162/// Verify navigation within a budget shared with the rest of the navigation.
163///
164/// # Arguments
165///
166/// * `adapter` - The engine adapter to use
167/// * `expected_url` - The expected URL (optional, for pattern matching)
168/// * `start_url` - The URL before navigation
169/// * `options` - Navigation options
170/// * `deadline` - The budget shared with every other check
171///
172/// # Returns
173///
174/// The verification result
175pub async fn verify_navigation_within(
176    adapter: &dyn EngineAdapter,
177    expected_url: Option<&str>,
178    start_url: &str,
179    options: &NavigationOptions,
180    deadline: &Deadline,
181) -> Result<NavigationVerificationResult, EngineError> {
182    // Verification never gets more time than the navigation has left, however
183    // generous its own timeout is.
184    let budget = options.verification_timeout.min(deadline.remaining());
185    let start_time = Instant::now();
186    let mut attempts = 0u32;
187
188    while start_time.elapsed() < budget {
189        attempts += 1;
190
191        let actual_url = match adapter.url().await {
192            Ok(url) => url,
193            Err(e) if is_navigation_error(&e.to_string()) => {
194                return Ok(NavigationVerificationResult {
195                    verified: false,
196                    actual_url: String::new(),
197                    reason: "error during verification".to_string(),
198                    attempts,
199                });
200            }
201            Err(e) => return Err(e),
202        };
203
204        // If expected URL is provided, verify it matches
205        if let Some(expected) = expected_url {
206            if actual_url == expected {
207                return Ok(NavigationVerificationResult {
208                    verified: true,
209                    actual_url,
210                    reason: "exact URL match".to_string(),
211                    attempts,
212                });
213            }
214
215            if actual_url.contains(expected) || actual_url.starts_with(expected) {
216                return Ok(NavigationVerificationResult {
217                    verified: true,
218                    actual_url,
219                    reason: "URL pattern match".to_string(),
220                    attempts,
221                });
222            }
223        } else {
224            // No expected URL - just verify URL changed from start
225            if actual_url != start_url {
226                return Ok(NavigationVerificationResult {
227                    verified: true,
228                    actual_url,
229                    reason: "URL changed from start".to_string(),
230                    attempts,
231                });
232            }
233        }
234
235        deadline.sleep_at_most(Duration::from_millis(100)).await;
236    }
237
238    // Final check
239    let actual_url = adapter.url().await?;
240
241    Ok(NavigationVerificationResult {
242        verified: false,
243        actual_url: actual_url.clone(),
244        reason: format!(
245            "URL mismatch: expected {:?}, got \"{}\"",
246            expected_url, actual_url
247        ),
248        attempts,
249    })
250}
251
252/// Wait for the URL to stop changing, reporting what was observed.
253///
254/// # Arguments
255///
256/// * `adapter` - The engine adapter to use
257/// * `options` - Navigation options
258/// * `deadline` - The budget shared with every other check in this navigation
259/// * `name` - Check name recorded in the evidence
260///
261/// # Returns
262///
263/// The evidence for this check
264pub async fn url_stable_within(
265    adapter: &dyn EngineAdapter,
266    options: &NavigationOptions,
267    deadline: &Deadline,
268    name: &str,
269) -> Result<CheckRecord, EngineError> {
270    let started_at_ms = deadline.elapsed_ms();
271    let mut stable_count = 0u32;
272    let mut last_url = adapter.url().await?;
273
274    while stable_count < options.stable_checks {
275        if deadline.expired() {
276            let elapsed = deadline.elapsed_ms() - started_at_ms;
277            return Ok(CheckRecord::unsatisfied(
278                name,
279                json!({
280                    "url": last_url,
281                    "stableChecks": stable_count,
282                    "requiredChecks": options.stable_checks,
283                    "reason": "deadline reached",
284                }),
285            )
286            .with_timing(started_at_ms, elapsed));
287        }
288
289        deadline.sleep_at_most(options.check_interval).await;
290
291        let current_url = adapter.url().await?;
292
293        if current_url == last_url {
294            stable_count += 1;
295        } else {
296            stable_count = 0;
297            last_url = current_url;
298        }
299    }
300
301    let elapsed = deadline.elapsed_ms() - started_at_ms;
302    Ok(CheckRecord::satisfied(
303        name,
304        json!({ "url": last_url, "stableChecks": stable_count }),
305    )
306    .with_timing(started_at_ms, elapsed))
307}
308
309/// Wait for URL to stabilize (no more redirects).
310///
311/// # Arguments
312///
313/// * `adapter` - The engine adapter to use
314/// * `options` - Navigation options
315/// * `reason` - Reason for stabilization (for logging)
316///
317/// # Returns
318///
319/// `true` if stabilized, `false` if timeout
320pub async fn wait_for_url_stabilization(
321    adapter: &dyn EngineAdapter,
322    options: &NavigationOptions,
323    reason: &str,
324) -> Result<bool, EngineError> {
325    let deadline = Deadline::new(options.timeout);
326    let record = url_stable_within(adapter, options, &deadline, reason).await?;
327    Ok(record.satisfied)
328}
329
330/// Navigate to a URL.
331///
332/// # Arguments
333///
334/// * `adapter` - The engine adapter to use
335/// * `url` - The URL to navigate to
336/// * `options` - Navigation options
337///
338/// # Returns
339///
340/// The result of the navigation
341pub async fn goto(
342    adapter: &dyn EngineAdapter,
343    url: &str,
344    options: &NavigationOptions,
345) -> Result<NavigationResult, EngineError> {
346    let start_url = adapter.url().await?;
347    // One monotonic budget covers stabilization before, stabilization after and
348    // verification. Each step used to get a full timeout of its own, so the
349    // total wait could be several times the timeout the caller asked for.
350    let deadline = Deadline::new(options.timeout);
351    let mut readiness = ReadinessOutcome::new(&deadline);
352
353    // Wait for URL to stabilize before navigation (if requested)
354    if options.wait_for_stable_url_before {
355        let record = url_stable_within(adapter, options, &deadline, "url_stable_before").await?;
356        readiness.record(record);
357    }
358
359    // Perform navigation
360    match adapter.goto(url).await {
361        Ok(_) => {}
362        Err(e) if is_navigation_error(&e.to_string()) => {
363            return Ok(NavigationResult::interrupted("navigation was interrupted"));
364        }
365        Err(e) => return Err(e),
366    }
367
368    // Wait for URL to stabilize after navigation (if requested)
369    if options.wait_for_stable_url_after {
370        let record = url_stable_within(adapter, options, &deadline, "url_stable_after").await?;
371        readiness.record(record);
372    }
373
374    // Verify navigation if requested
375    if options.verify {
376        if deadline.expired() {
377            // The budget is gone, so verification never ran. Saying so is the
378            // point: a check that did not run is not a check that passed.
379            readiness.defer("verify_navigation");
380        } else {
381            let started_at_ms = deadline.elapsed_ms();
382            let verification =
383                verify_navigation_within(adapter, Some(url), &start_url, options, &deadline)
384                    .await?;
385            let detail = json!({
386                "actualUrl": verification.actual_url,
387                "reason": verification.reason,
388                "attempts": verification.attempts,
389            });
390            let record = if verification.verified {
391                CheckRecord::satisfied("verify_navigation", detail)
392            } else {
393                CheckRecord::unsatisfied("verify_navigation", detail)
394            }
395            .with_timing(started_at_ms, deadline.elapsed_ms() - started_at_ms);
396            readiness.record(record);
397            let readiness = readiness.finish(&deadline);
398
399            return Ok(NavigationResult {
400                navigated: true,
401                verified: verification.verified,
402                actual_url: Some(verification.actual_url.clone()),
403                reason: Some(if readiness.ready {
404                    verification.reason
405                } else {
406                    readiness.summary()
407                }),
408                readiness: Some(readiness),
409            });
410        }
411    }
412
413    let readiness = readiness.finish(&deadline);
414    let actual_url = adapter.url().await?;
415    Ok(NavigationResult::success(actual_url).with_readiness(readiness))
416}
417
418/// Wait for navigation to complete.
419///
420/// # Arguments
421///
422/// * `adapter` - The engine adapter to use
423/// * `timeout_ms` - Timeout in milliseconds
424///
425/// # Returns
426///
427/// `true` if navigation completed, `false` on timeout or error
428pub async fn wait_for_navigation(
429    adapter: &dyn EngineAdapter,
430    timeout_ms: u64,
431) -> Result<bool, EngineError> {
432    match adapter.wait_for_navigation(timeout_ms).await {
433        Ok(_) => Ok(true),
434        Err(e) if is_navigation_error(&e.to_string()) => Ok(false),
435        Err(e) => Err(e),
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::core::stub_engine::StubEngine;
443
444    #[test]
445    fn navigation_options_default() {
446        let options = NavigationOptions::default();
447        assert_eq!(options.wait_until, WaitUntil::DomContentLoaded);
448        assert!(options.wait_for_stable_url_before);
449        assert!(options.wait_for_stable_url_after);
450        assert!(options.verify);
451        assert_eq!(options.stable_checks, 3);
452    }
453
454    #[test]
455    fn wait_until_display() {
456        assert_eq!(WaitUntil::DomContentLoaded.to_string(), "domcontentloaded");
457        assert_eq!(WaitUntil::Load.to_string(), "load");
458        assert_eq!(WaitUntil::NetworkIdle.to_string(), "networkidle");
459    }
460
461    #[test]
462    fn navigation_result_success() {
463        let result = NavigationResult::success("https://example.com".to_string());
464        assert!(result.navigated);
465        assert!(result.verified);
466        assert_eq!(result.actual_url, Some("https://example.com".to_string()));
467    }
468
469    #[test]
470    fn navigation_result_interrupted() {
471        let result = NavigationResult::interrupted("page was closed");
472        assert!(!result.navigated);
473        assert!(!result.verified);
474        assert!(result.actual_url.is_none());
475        assert_eq!(result.reason, Some("page was closed".to_string()));
476    }
477
478    /// Options that keep the tests fast: a small budget polled often.
479    fn quick_options(timeout_ms: u64) -> NavigationOptions {
480        NavigationOptions {
481            timeout: Duration::from_millis(timeout_ms),
482            verification_timeout: Duration::from_millis(timeout_ms),
483            stable_checks: 2,
484            check_interval: Duration::from_millis(5),
485            ..NavigationOptions::default()
486        }
487    }
488
489    #[tokio::test]
490    async fn an_unstable_url_is_not_reported_as_verified() {
491        // Regression test for issue #89: `wait_for_url_stabilization` returned
492        // `false`, `goto` discarded it, and the navigation was reported as a
493        // verified success anyway.
494        let engine = StubEngine::never_stable();
495        let options = NavigationOptions {
496            wait_for_stable_url_before: false,
497            verify: false,
498            ..quick_options(60)
499        };
500
501        let result = goto(&engine, "https://example.com/target", &options)
502            .await
503            .unwrap();
504
505        assert!(result.navigated);
506        assert!(!result.verified);
507        let readiness = result.readiness.expect("readiness evidence");
508        assert!(!readiness.ready);
509        assert_eq!(readiness.failed, vec!["url_stable_after".to_string()]);
510        assert!(result.reason.unwrap().contains("url_stable_after"));
511    }
512
513    #[tokio::test]
514    async fn a_stable_url_is_recorded_as_satisfied_evidence() {
515        let engine = StubEngine::fixed("https://example.com/target");
516        let options = NavigationOptions {
517            wait_for_stable_url_before: false,
518            verify: false,
519            ..quick_options(2_000)
520        };
521
522        let result = goto(&engine, "https://example.com/target", &options)
523            .await
524            .unwrap();
525
526        assert!(result.verified);
527        let readiness = result.readiness.expect("readiness evidence");
528        assert!(readiness.ready);
529        assert_eq!(readiness.satisfied, vec!["url_stable_after".to_string()]);
530        assert_eq!(readiness.evidence.len(), 1);
531        assert_eq!(readiness.evidence[0].detail["stableChecks"], 2);
532    }
533
534    #[tokio::test]
535    async fn goto_shares_one_budget_across_every_check() {
536        // Regression test for issue #89: stabilization before, stabilization
537        // after and verification each used to get a full timeout of their own,
538        // so a 60ms navigation could wait 180ms or more.
539        let engine = StubEngine::never_stable();
540        let options = quick_options(60);
541
542        let started = Instant::now();
543        let result = goto(&engine, "https://example.com/target", &options)
544            .await
545            .unwrap();
546        let elapsed = started.elapsed();
547
548        assert!(
549            elapsed < Duration::from_millis(120),
550            "goto took {elapsed:?}, which is more than one 60ms budget"
551        );
552        let readiness = result.readiness.expect("readiness evidence");
553        assert!(readiness.elapsed_ms >= 60);
554        assert_eq!(
555            readiness.failed,
556            vec![
557                "url_stable_before".to_string(),
558                "url_stable_after".to_string()
559            ]
560        );
561    }
562
563    #[tokio::test]
564    async fn verification_that_never_ran_is_pending_not_satisfied() {
565        let engine = StubEngine::never_stable();
566        let options = quick_options(40);
567
568        let result = goto(&engine, "https://example.com/target", &options)
569            .await
570            .unwrap();
571
572        let readiness = result.readiness.expect("readiness evidence");
573        assert_eq!(readiness.pending, vec!["verify_navigation".to_string()]);
574        assert!(!readiness
575            .satisfied
576            .contains(&"verify_navigation".to_string()));
577    }
578
579    #[tokio::test]
580    async fn verification_never_outlives_the_navigation_budget() {
581        // The verification timeout is far more generous than the budget the
582        // navigation has left, so the deadline must win.
583        let engine = StubEngine::fixed("https://example.com/start");
584        let options = NavigationOptions {
585            verification_timeout: Duration::from_secs(30),
586            ..quick_options(50)
587        };
588        let deadline = Deadline::new(Duration::from_millis(50));
589
590        let started = Instant::now();
591        let verification = verify_navigation_within(
592            &engine,
593            Some("https://example.com/target"),
594            "https://example.com/start",
595            &options,
596            &deadline,
597        )
598        .await
599        .unwrap();
600
601        assert!(!verification.verified);
602        assert!(
603            started.elapsed() < Duration::from_millis(500),
604            "verification outlived the navigation budget"
605        );
606    }
607
608    #[tokio::test]
609    async fn stabilization_evidence_carries_its_timing_window() {
610        let engine = StubEngine::fixed("https://example.com/target");
611        let deadline = Deadline::new(Duration::from_millis(500));
612        let options = quick_options(500);
613
614        let record = url_stable_within(&engine, &options, &deadline, "url_stable_after")
615            .await
616            .unwrap();
617
618        assert!(record.satisfied);
619        assert_eq!(record.name, "url_stable_after");
620        assert!(record.elapsed_ms >= 5);
621    }
622
623    #[tokio::test]
624    async fn the_legacy_stabilization_wrapper_still_reports_a_bool() {
625        let stable = StubEngine::fixed("https://example.com/target");
626        let unstable = StubEngine::never_stable();
627        let options = quick_options(40);
628
629        assert!(wait_for_url_stabilization(&stable, &options, "after")
630            .await
631            .unwrap());
632        assert!(!wait_for_url_stabilization(&unstable, &options, "after")
633            .await
634            .unwrap());
635    }
636}