viewpoint_core/wait/navigation_waiter/
mod.rs

1//! Navigation waiter for detecting navigation triggered by actions.
2//!
3//! This module provides `NavigationWaiter` which listens for CDP frame navigation
4//! events to detect when an action (like click or press) triggers a page navigation.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use tokio::sync::{Mutex, broadcast};
10use tokio::time::{Instant, timeout};
11use tracing::{debug, instrument, trace};
12use viewpoint_cdp::CdpEvent;
13
14use super::{DocumentLoadState, LoadStateWaiter};
15use crate::error::WaitError;
16
17/// Duration to wait for navigation to be triggered after an action.
18const NAVIGATION_DETECTION_WINDOW: Duration = Duration::from_millis(50);
19
20/// Default navigation timeout.
21const DEFAULT_NAVIGATION_TIMEOUT: Duration = Duration::from_secs(30);
22
23/// State of navigation detection.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25enum NavigationState {
26    /// No navigation detected yet.
27    Idle,
28    /// Navigation has been triggered (frameNavigated event received).
29    Navigating,
30    /// Navigation completed.
31    Complete,
32}
33
34/// Waiter that detects and waits for navigation triggered by actions.
35///
36/// # Usage
37///
38/// 1. Create a waiter before performing an action
39/// 2. Perform the action (click, press, etc.)
40/// 3. Call `wait_for_navigation_if_triggered` to wait if navigation occurred
41///
42/// # Example (internal use)
43///
44/// ```no_run
45/// # use viewpoint_core::wait::NavigationWaiter;
46/// # async fn example(
47/// #     event_rx: tokio::sync::broadcast::Receiver<viewpoint_cdp::CdpEvent>,
48/// #     session_id: String,
49/// #     frame_id: String,
50/// # ) -> Result<(), viewpoint_core::error::WaitError> {
51/// let waiter = NavigationWaiter::new(event_rx, session_id, frame_id);
52/// // ... perform action ...
53/// waiter.wait_for_navigation_if_triggered().await?;
54/// # Ok(())
55/// # }
56/// ```
57#[derive(Debug)]
58pub struct NavigationWaiter {
59    /// Event receiver for CDP events.
60    event_rx: broadcast::Receiver<CdpEvent>,
61    /// Session ID to filter events for.
62    session_id: String,
63    /// Main frame ID to track.
64    frame_id: String,
65    /// Current navigation state.
66    state: Arc<Mutex<NavigationState>>,
67    /// When the waiter was created (to track detection window).
68    created_at: Instant,
69    /// Navigation timeout.
70    navigation_timeout: Duration,
71}
72
73impl NavigationWaiter {
74    /// Create a new navigation waiter.
75    ///
76    /// # Arguments
77    ///
78    /// * `event_rx` - CDP event receiver
79    /// * `session_id` - Session ID to filter events for
80    /// * `frame_id` - Main frame ID to track navigation for
81    pub fn new(
82        event_rx: broadcast::Receiver<CdpEvent>,
83        session_id: String,
84        frame_id: String,
85    ) -> Self {
86        debug!(
87            session_id = %session_id,
88            frame_id = %frame_id,
89            "Created NavigationWaiter"
90        );
91        Self {
92            event_rx,
93            session_id,
94            frame_id,
95            state: Arc::new(Mutex::new(NavigationState::Idle)),
96            created_at: Instant::now(),
97            navigation_timeout: DEFAULT_NAVIGATION_TIMEOUT,
98        }
99    }
100
101    /// Set the navigation timeout.
102    ///
103    /// This is the maximum time to wait for navigation to complete after
104    /// it has been detected. Default is 30 seconds.
105    #[must_use]
106    pub fn timeout(mut self, timeout: Duration) -> Self {
107        self.navigation_timeout = timeout;
108        self
109    }
110
111    /// Wait for navigation to complete if one was triggered by the action.
112    ///
113    /// This method:
114    /// 1. Waits up to 50ms for a navigation event to be triggered
115    /// 2. If navigation is detected, waits for the load state to reach `Load`
116    /// 3. If no navigation is detected, returns immediately
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if:
121    /// - Navigation times out
122    /// - Page is closed during navigation
123    #[instrument(level = "debug", skip(self))]
124    pub async fn wait_for_navigation_if_triggered(mut self) -> Result<bool, WaitError> {
125        // Check if navigation was triggered within the detection window
126        let navigation_detected = self.detect_navigation().await;
127
128        if !navigation_detected {
129            debug!("No navigation detected within detection window");
130            return Ok(false);
131        }
132
133        debug!("Navigation detected, waiting for load state");
134
135        // Navigation was triggered, wait for it to complete
136        self.wait_for_load_complete().await?;
137
138        debug!("Navigation completed successfully");
139        Ok(true)
140    }
141
142    /// Detect if navigation was triggered within the detection window.
143    async fn detect_navigation(&mut self) -> bool {
144        let remaining_window = NAVIGATION_DETECTION_WINDOW
145            .checked_sub(self.created_at.elapsed())
146            .unwrap_or(Duration::ZERO);
147
148        if remaining_window.is_zero() {
149            // Detection window already passed, check if we already have a navigation event
150            return *self.state.lock().await != NavigationState::Idle;
151        }
152
153        // Wait for navigation event within the detection window
154        let result = timeout(remaining_window, self.wait_for_navigation_event()).await;
155
156        if let Ok(true) = result {
157            trace!("Navigation event received within detection window");
158            true
159        } else {
160            trace!("No navigation event within detection window");
161            false
162        }
163    }
164
165    /// Wait for a navigation event (frameNavigated).
166    async fn wait_for_navigation_event(&mut self) -> bool {
167        loop {
168            let event = match self.event_rx.recv().await {
169                Ok(event) => event,
170                Err(broadcast::error::RecvError::Closed) => return false,
171                Err(broadcast::error::RecvError::Lagged(_)) => continue,
172            };
173
174            // Filter for our session
175            if event.session_id.as_deref() != Some(&self.session_id) {
176                continue;
177            }
178
179            // Check for navigation events
180            match event.method.as_str() {
181                "Page.frameNavigated" => {
182                    if let Some(params) = &event.params {
183                        // Check if this is the main frame
184                        if let Some(frame) = params.get("frame") {
185                            if let Some(frame_id) = frame.get("id").and_then(|v| v.as_str()) {
186                                // Check if this is the main frame or a child of the main frame
187                                let parent_id = frame.get("parentId").and_then(|v| v.as_str());
188                                if frame_id == self.frame_id || parent_id.is_none() {
189                                    debug!(frame_id = %frame_id, "Frame navigation detected");
190                                    *self.state.lock().await = NavigationState::Navigating;
191                                    return true;
192                                }
193                            }
194                        }
195                    }
196                }
197                "Page.navigatedWithinDocument" => {
198                    if let Some(params) = &event.params {
199                        if let Some(frame_id) = params.get("frameId").and_then(|v| v.as_str()) {
200                            if frame_id == self.frame_id {
201                                debug!(
202                                    frame_id = %frame_id,
203                                    "Within-document navigation detected"
204                                );
205                                // Within-document navigation (e.g., hash change) completes immediately
206                                *self.state.lock().await = NavigationState::Complete;
207                                return true;
208                            }
209                        }
210                    }
211                }
212                _ => {}
213            }
214        }
215    }
216
217    /// Wait for navigation to complete (reach Load state).
218    async fn wait_for_load_complete(&mut self) -> Result<(), WaitError> {
219        // Check if it was a within-document navigation (already complete)
220        if *self.state.lock().await == NavigationState::Complete {
221            return Ok(());
222        }
223
224        // Create a new load state waiter
225        let mut load_waiter = LoadStateWaiter::new(
226            self.event_rx.resubscribe(),
227            self.session_id.clone(),
228            self.frame_id.clone(),
229        );
230
231        // Set commit received since navigation already started
232        load_waiter.set_commit_received().await;
233
234        // Wait for Load state
235        load_waiter
236            .wait_for_load_state_with_timeout(DocumentLoadState::Load, self.navigation_timeout)
237            .await?;
238
239        *self.state.lock().await = NavigationState::Complete;
240        Ok(())
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_navigation_detection_window_is_reasonable() {
250        assert_eq!(NAVIGATION_DETECTION_WINDOW, Duration::from_millis(50));
251    }
252
253    #[test]
254    fn test_default_timeout() {
255        assert_eq!(DEFAULT_NAVIGATION_TIMEOUT, Duration::from_secs(30));
256    }
257}