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;