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