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}