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 std::time::{Duration, Instant};
10
11/// Options for navigation operations.
12#[derive(Debug, Clone)]
13pub struct NavigationOptions {
14    /// Wait until condition for navigation.
15    pub wait_until: WaitUntil,
16    /// Navigation timeout.
17    pub timeout: Duration,
18    /// Whether to wait for URL to stabilize before navigation.
19    pub wait_for_stable_url_before: bool,
20    /// Whether to wait for URL to stabilize after navigation.
21    pub wait_for_stable_url_after: bool,
22    /// Whether to verify the navigation.
23    pub verify: bool,
24    /// Verification timeout.
25    pub verification_timeout: Duration,
26    /// Number of consecutive stable checks required.
27    pub stable_checks: u32,
28    /// Interval between stability checks.
29    pub check_interval: Duration,
30}
31
32impl Default for NavigationOptions {
33    fn default() -> Self {
34        Self {
35            wait_until: WaitUntil::DomContentLoaded,
36            timeout: TIMING.navigation_timeout,
37            wait_for_stable_url_before: true,
38            wait_for_stable_url_after: true,
39            verify: true,
40            verification_timeout: TIMING.verification_timeout,
41            stable_checks: 3,
42            check_interval: Duration::from_secs(1),
43        }
44    }
45}
46
47/// Wait until conditions for page load.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum WaitUntil {
50    /// Wait for DOMContentLoaded event.
51    #[default]
52    DomContentLoaded,
53    /// Wait for load event.
54    Load,
55    /// Wait for network to be idle.
56    NetworkIdle,
57}
58
59impl std::fmt::Display for WaitUntil {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            WaitUntil::DomContentLoaded => write!(f, "domcontentloaded"),
63            WaitUntil::Load => write!(f, "load"),
64            WaitUntil::NetworkIdle => write!(f, "networkidle"),
65        }
66    }
67}
68
69/// Result of a navigation verification.
70#[derive(Debug, Clone)]
71pub struct NavigationVerificationResult {
72    /// Whether the navigation was verified as successful.
73    pub verified: bool,
74    /// The actual URL after navigation.
75    pub actual_url: String,
76    /// The reason for the verification result.
77    pub reason: String,
78    /// Number of verification attempts.
79    pub attempts: u32,
80}
81
82/// Result of a navigation operation.
83#[derive(Debug, Clone)]
84pub struct NavigationResult {
85    /// Whether navigation was performed.
86    pub navigated: bool,
87    /// Whether the navigation was verified as successful.
88    pub verified: bool,
89    /// The actual URL after navigation.
90    pub actual_url: Option<String>,
91    /// The reason for the result.
92    pub reason: Option<String>,
93}
94
95impl NavigationResult {
96    /// Create a successful navigation result.
97    pub fn success(actual_url: String) -> Self {
98        Self {
99            navigated: true,
100            verified: true,
101            actual_url: Some(actual_url),
102            reason: Some("navigation completed".to_string()),
103        }
104    }
105
106    /// Create a result indicating navigation was interrupted.
107    pub fn interrupted(reason: impl Into<String>) -> Self {
108        Self {
109            navigated: false,
110            verified: false,
111            actual_url: None,
112            reason: Some(reason.into()),
113        }
114    }
115}
116
117/// Verify that navigation completed successfully.
118///
119/// # Arguments
120///
121/// * `adapter` - The engine adapter to use
122/// * `expected_url` - The expected URL (optional, for pattern matching)
123/// * `start_url` - The URL before navigation
124/// * `options` - Navigation options
125///
126/// # Returns
127///
128/// The verification result
129pub async fn verify_navigation(
130    adapter: &dyn EngineAdapter,
131    expected_url: Option<&str>,
132    start_url: &str,
133    options: &NavigationOptions,
134) -> Result<NavigationVerificationResult, EngineError> {
135    let start_time = Instant::now();
136    let mut attempts = 0u32;
137
138    while start_time.elapsed() < options.verification_timeout {
139        attempts += 1;
140
141        let actual_url = match adapter.url().await {
142            Ok(url) => url,
143            Err(e) if is_navigation_error(&e.to_string()) => {
144                return Ok(NavigationVerificationResult {
145                    verified: false,
146                    actual_url: String::new(),
147                    reason: "error during verification".to_string(),
148                    attempts,
149                });
150            }
151            Err(e) => return Err(e),
152        };
153
154        // If expected URL is provided, verify it matches
155        if let Some(expected) = expected_url {
156            if actual_url == expected {
157                return Ok(NavigationVerificationResult {
158                    verified: true,
159                    actual_url,
160                    reason: "exact URL match".to_string(),
161                    attempts,
162                });
163            }
164
165            if actual_url.contains(expected) || actual_url.starts_with(expected) {
166                return Ok(NavigationVerificationResult {
167                    verified: true,
168                    actual_url,
169                    reason: "URL pattern match".to_string(),
170                    attempts,
171                });
172            }
173        } else {
174            // No expected URL - just verify URL changed from start
175            if actual_url != start_url {
176                return Ok(NavigationVerificationResult {
177                    verified: true,
178                    actual_url,
179                    reason: "URL changed from start".to_string(),
180                    attempts,
181                });
182            }
183        }
184
185        tokio::time::sleep(Duration::from_millis(100)).await;
186    }
187
188    // Final check
189    let actual_url = adapter.url().await?;
190
191    Ok(NavigationVerificationResult {
192        verified: false,
193        actual_url: actual_url.clone(),
194        reason: format!(
195            "URL mismatch: expected {:?}, got \"{}\"",
196            expected_url, actual_url
197        ),
198        attempts,
199    })
200}
201
202/// Wait for URL to stabilize (no more redirects).
203///
204/// # Arguments
205///
206/// * `adapter` - The engine adapter to use
207/// * `options` - Navigation options
208/// * `reason` - Reason for stabilization (for logging)
209///
210/// # Returns
211///
212/// `true` if stabilized, `false` if timeout
213pub async fn wait_for_url_stabilization(
214    adapter: &dyn EngineAdapter,
215    options: &NavigationOptions,
216    _reason: &str,
217) -> Result<bool, EngineError> {
218    let start_time = Instant::now();
219    let mut stable_count = 0u32;
220    let mut last_url = adapter.url().await?;
221
222    while stable_count < options.stable_checks {
223        // Check timeout
224        if start_time.elapsed() > options.timeout {
225            return Ok(false);
226        }
227
228        tokio::time::sleep(options.check_interval).await;
229
230        let current_url = adapter.url().await?;
231
232        if current_url == last_url {
233            stable_count += 1;
234        } else {
235            stable_count = 0;
236            last_url = current_url;
237        }
238    }
239
240    Ok(true)
241}
242
243/// Navigate to a URL.
244///
245/// # Arguments
246///
247/// * `adapter` - The engine adapter to use
248/// * `url` - The URL to navigate to
249/// * `options` - Navigation options
250///
251/// # Returns
252///
253/// The result of the navigation
254pub async fn goto(
255    adapter: &dyn EngineAdapter,
256    url: &str,
257    options: &NavigationOptions,
258) -> Result<NavigationResult, EngineError> {
259    let start_url = adapter.url().await?;
260
261    // Wait for URL to stabilize before navigation (if requested)
262    if options.wait_for_stable_url_before {
263        wait_for_url_stabilization(adapter, options, "before navigation").await?;
264    }
265
266    // Perform navigation
267    match adapter.goto(url).await {
268        Ok(_) => {}
269        Err(e) if is_navigation_error(&e.to_string()) => {
270            return Ok(NavigationResult::interrupted("navigation was interrupted"));
271        }
272        Err(e) => return Err(e),
273    }
274
275    // Wait for URL to stabilize after navigation (if requested)
276    if options.wait_for_stable_url_after {
277        wait_for_url_stabilization(adapter, options, "after navigation").await?;
278    }
279
280    // Verify navigation if requested
281    if options.verify {
282        let verification = verify_navigation(adapter, Some(url), &start_url, options).await?;
283
284        return Ok(NavigationResult {
285            navigated: true,
286            verified: verification.verified,
287            actual_url: Some(verification.actual_url),
288            reason: Some(verification.reason),
289        });
290    }
291
292    let actual_url = adapter.url().await?;
293    Ok(NavigationResult::success(actual_url))
294}
295
296/// Wait for navigation to complete.
297///
298/// # Arguments
299///
300/// * `adapter` - The engine adapter to use
301/// * `timeout_ms` - Timeout in milliseconds
302///
303/// # Returns
304///
305/// `true` if navigation completed, `false` on timeout or error
306pub async fn wait_for_navigation(
307    adapter: &dyn EngineAdapter,
308    timeout_ms: u64,
309) -> Result<bool, EngineError> {
310    match adapter.wait_for_navigation(timeout_ms).await {
311        Ok(_) => Ok(true),
312        Err(e) if is_navigation_error(&e.to_string()) => Ok(false),
313        Err(e) => Err(e),
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn navigation_options_default() {
323        let options = NavigationOptions::default();
324        assert_eq!(options.wait_until, WaitUntil::DomContentLoaded);
325        assert!(options.wait_for_stable_url_before);
326        assert!(options.wait_for_stable_url_after);
327        assert!(options.verify);
328        assert_eq!(options.stable_checks, 3);
329    }
330
331    #[test]
332    fn wait_until_display() {
333        assert_eq!(WaitUntil::DomContentLoaded.to_string(), "domcontentloaded");
334        assert_eq!(WaitUntil::Load.to_string(), "load");
335        assert_eq!(WaitUntil::NetworkIdle.to_string(), "networkidle");
336    }
337
338    #[test]
339    fn navigation_result_success() {
340        let result = NavigationResult::success("https://example.com".to_string());
341        assert!(result.navigated);
342        assert!(result.verified);
343        assert_eq!(result.actual_url, Some("https://example.com".to_string()));
344    }
345
346    #[test]
347    fn navigation_result_interrupted() {
348        let result = NavigationResult::interrupted("page was closed");
349        assert!(!result.navigated);
350        assert!(!result.verified);
351        assert!(result.actual_url.is_none());
352        assert_eq!(result.reason, Some("page was closed".to_string()));
353    }
354}