viewpoint_core/page/navigation/
mod.rs

1//! Navigation types and builder.
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use tracing::{debug, info, instrument, trace, warn};
7use viewpoint_cdp::protocol::page::{NavigateParams, NavigateResult};
8
9use crate::error::NavigationError;
10use crate::wait::{DocumentLoadState, LoadStateWaiter};
11
12use super::{DEFAULT_NAVIGATION_TIMEOUT, Page};
13
14/// Response from a navigation.
15#[derive(Debug, Clone)]
16pub struct NavigationResponse {
17    /// The URL that was navigated to (final URL after redirects).
18    url: String,
19    /// The frame ID that navigated.
20    frame_id: String,
21    /// HTTP status code of the response (e.g., 200, 404, 302).
22    status: Option<u16>,
23    /// HTTP response headers.
24    headers: Option<HashMap<String, String>>,
25}
26
27impl NavigationResponse {
28    /// Create a new navigation response.
29    pub(crate) fn new(url: String, frame_id: String) -> Self {
30        Self {
31            url,
32            frame_id,
33            status: None,
34            headers: None,
35        }
36    }
37
38    /// Create a navigation response with response data.
39    pub(crate) fn with_response(
40        url: String,
41        frame_id: String,
42        status: u16,
43        headers: HashMap<String, String>,
44    ) -> Self {
45        Self {
46            url,
47            frame_id,
48            status: Some(status),
49            headers: Some(headers),
50        }
51    }
52
53    /// Get the final URL after any redirects.
54    pub fn url(&self) -> &str {
55        &self.url
56    }
57
58    /// Get the frame ID that navigated.
59    pub fn frame_id(&self) -> &str {
60        &self.frame_id
61    }
62
63    /// Get the HTTP status code of the final response.
64    ///
65    /// Returns `None` if status was not captured (e.g., for `about:blank`).
66    pub fn status(&self) -> Option<u16> {
67        self.status
68    }
69
70    /// Get the HTTP response headers.
71    ///
72    /// Returns `None` if headers were not captured.
73    pub fn headers(&self) -> Option<&HashMap<String, String>> {
74        self.headers.as_ref()
75    }
76
77    /// Check if the navigation resulted in an OK response (2xx status).
78    pub fn ok(&self) -> bool {
79        self.status.is_none_or(|s| (200..300).contains(&s))
80    }
81}
82
83/// Builder for configuring page navigation.
84#[derive(Debug)]
85pub struct GotoBuilder<'a> {
86    page: &'a Page,
87    url: String,
88    wait_until: DocumentLoadState,
89    timeout: Duration,
90    referer: Option<String>,
91}
92
93impl<'a> GotoBuilder<'a> {
94    /// Create a new navigation builder.
95    pub(crate) fn new(page: &'a Page, url: String) -> Self {
96        Self {
97            page,
98            url,
99            wait_until: DocumentLoadState::default(),
100            timeout: DEFAULT_NAVIGATION_TIMEOUT,
101            referer: None,
102        }
103    }
104
105    /// Set the load state to wait for.
106    ///
107    /// Default is `DocumentLoadState::Load`.
108    ///
109    /// # Example
110    ///
111    /// ```no_run
112    /// use viewpoint_core::DocumentLoadState;
113    ///
114    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
115    /// // Wait only for DOM content loaded (faster)
116    /// page.goto("https://example.com")
117    ///     .wait_until(DocumentLoadState::DomContentLoaded)
118    ///     .goto()
119    ///     .await?;
120    ///
121    /// // Wait for network idle (slower but more complete)
122    /// page.goto("https://example.com")
123    ///     .wait_until(DocumentLoadState::NetworkIdle)
124    ///     .goto()
125    ///     .await?;
126    /// # Ok(())
127    /// # }
128    /// ```
129    #[must_use]
130    pub fn wait_until(mut self, state: DocumentLoadState) -> Self {
131        self.wait_until = state;
132        self
133    }
134
135    /// Set the navigation timeout.
136    ///
137    /// Default is 30 seconds.
138    ///
139    /// # Example
140    ///
141    /// ```no_run
142    /// use std::time::Duration;
143    ///
144    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
145    /// page.goto("https://slow-site.com")
146    ///     .timeout(Duration::from_secs(60))
147    ///     .goto()
148    ///     .await?;
149    /// # Ok(())
150    /// # }
151    /// ```
152    #[must_use]
153    pub fn timeout(mut self, timeout: Duration) -> Self {
154        self.timeout = timeout;
155        self
156    }
157
158    /// Set the referer header for the navigation.
159    ///
160    /// # Example
161    ///
162    /// ```no_run
163    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
164    /// page.goto("https://example.com")
165    ///     .referer("https://google.com")
166    ///     .goto()
167    ///     .await?;
168    /// # Ok(())
169    /// # }
170    /// ```
171    #[must_use]
172    pub fn referer(mut self, referer: impl Into<String>) -> Self {
173        self.referer = Some(referer.into());
174        self
175    }
176
177    /// Execute the navigation.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if:
182    /// - The page is closed
183    /// - Navigation fails (network error, SSL error, etc.)
184    /// - The wait times out
185    #[instrument(level = "debug", skip(self), fields(url = %self.url, wait_until = ?self.wait_until, timeout_ms = self.timeout.as_millis(), has_referer = self.referer.is_some()))]
186    pub async fn goto(self) -> Result<NavigationResponse, NavigationError> {
187        debug!("Executing navigation via GotoBuilder");
188        self.page
189            .navigate_internal(
190                &self.url,
191                self.wait_until,
192                self.timeout,
193                self.referer.as_deref(),
194            )
195            .await
196    }
197}
198
199// =============================================================================
200// Navigation Internal Methods
201// =============================================================================
202
203impl Page {
204    /// Navigate to a URL with the given options.
205    #[instrument(level = "info", skip(self), fields(target_id = %self.target_id, url = %url, wait_until = ?wait_until, timeout_ms = timeout.as_millis()))]
206    pub(crate) async fn navigate_internal(
207        &self,
208        url: &str,
209        wait_until: DocumentLoadState,
210        timeout: Duration,
211        referer: Option<&str>,
212    ) -> Result<NavigationResponse, NavigationError> {
213        if self.closed {
214            warn!("Attempted navigation on closed page");
215            return Err(NavigationError::Cancelled);
216        }
217
218        info!("Starting navigation");
219
220        // Create a load state waiter
221        let event_rx = self.connection.subscribe_events();
222        let mut waiter =
223            LoadStateWaiter::new(event_rx, self.session_id.clone(), self.frame_id.clone());
224        trace!("Created load state waiter");
225
226        // Send the navigation command
227        debug!("Sending Page.navigate command");
228        let result: NavigateResult = self
229            .connection
230            .send_command(
231                "Page.navigate",
232                Some(NavigateParams {
233                    url: url.to_string(),
234                    referrer: referer.map(ToString::to_string),
235                    transition_type: None,
236                    frame_id: None,
237                }),
238                Some(&self.session_id),
239            )
240            .await?;
241
242        debug!(frame_id = %result.frame_id, loader_id = ?result.loader_id, "Page.navigate completed");
243
244        // Check for navigation errors
245        // Note: Chrome reports HTTP error status codes (4xx, 5xx) as errors with
246        // "net::ERR_HTTP_RESPONSE_CODE_FAILURE" or "net::ERR_INVALID_AUTH_CREDENTIALS".
247        // Following Playwright's behavior, we treat these as successful navigations
248        // that return a response with the appropriate status code.
249        if let Some(ref error_text) = result.error_text {
250            let is_http_error = error_text == "net::ERR_HTTP_RESPONSE_CODE_FAILURE"
251                || error_text == "net::ERR_INVALID_AUTH_CREDENTIALS";
252
253            if !is_http_error {
254                warn!(error = %error_text, "Navigation failed with error");
255                return Err(NavigationError::NetworkError(error_text.clone()));
256            }
257            debug!(error = %error_text, "HTTP error response - continuing to capture status");
258        }
259
260        // Mark commit as received
261        trace!("Setting commit received");
262        waiter.set_commit_received().await;
263
264        // Wait for the target load state
265        debug!(wait_until = ?wait_until, "Waiting for load state");
266        waiter
267            .wait_for_load_state_with_timeout(wait_until, timeout)
268            .await?;
269
270        // Get response data captured during navigation
271        let response_data = waiter.response_data().await;
272
273        info!(frame_id = %result.frame_id, "Navigation completed successfully");
274
275        // Use the final URL from response data if available (handles redirects)
276        let final_url = response_data.url.unwrap_or_else(|| url.to_string());
277
278        // Build the response with captured data
279        if let Some(status) = response_data.status {
280            Ok(NavigationResponse::with_response(
281                final_url,
282                result.frame_id,
283                status,
284                response_data.headers,
285            ))
286        } else {
287            Ok(NavigationResponse::new(final_url, result.frame_id))
288        }
289    }
290}
291
292// =============================================================================
293// Navigation History Methods (impl extension for Page)
294// =============================================================================
295
296impl Page {
297    /// Navigate back in history.
298    ///
299    /// Returns `None` if there is no previous page in history.
300    ///
301    /// # Example
302    ///
303    /// ```no_run
304    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
305    /// if page.go_back().await?.is_some() {
306    ///     println!("Navigated back");
307    /// }
308    /// # Ok(())
309    /// # }
310    /// ```
311    #[instrument(level = "info", skip(self))]
312    pub async fn go_back(&self) -> Result<Option<NavigationResponse>, NavigationError> {
313        if self.closed {
314            return Err(NavigationError::Cancelled);
315        }
316
317        // Check if we can go back
318        let history: viewpoint_cdp::protocol::page::GetNavigationHistoryResult = self
319            .connection
320            .send_command(
321                "Page.getNavigationHistory",
322                None::<()>,
323                Some(&self.session_id),
324            )
325            .await?;
326
327        if history.current_index <= 0 {
328            debug!("No previous page in history");
329            return Ok(None);
330        }
331
332        // Navigate to the previous entry
333        let previous_entry = &history.entries[history.current_index as usize - 1];
334        self.connection
335            .send_command::<_, serde_json::Value>(
336                "Page.navigateToHistoryEntry",
337                Some(
338                    viewpoint_cdp::protocol::page::NavigateToHistoryEntryParams {
339                        entry_id: previous_entry.id,
340                    },
341                ),
342                Some(&self.session_id),
343            )
344            .await?;
345
346        info!("Navigated back to {}", previous_entry.url);
347        Ok(Some(NavigationResponse::new(
348            previous_entry.url.clone(),
349            self.frame_id.clone(),
350        )))
351    }
352
353    /// Navigate forward in history.
354    ///
355    /// Returns `None` if there is no next page in history.
356    #[instrument(level = "info", skip(self))]
357    pub async fn go_forward(&self) -> Result<Option<NavigationResponse>, NavigationError> {
358        if self.closed {
359            return Err(NavigationError::Cancelled);
360        }
361
362        // Check if we can go forward
363        let history: viewpoint_cdp::protocol::page::GetNavigationHistoryResult = self
364            .connection
365            .send_command(
366                "Page.getNavigationHistory",
367                None::<()>,
368                Some(&self.session_id),
369            )
370            .await?;
371
372        let next_index = history.current_index as usize + 1;
373        if next_index >= history.entries.len() {
374            debug!("No next page in history");
375            return Ok(None);
376        }
377
378        // Navigate to the next entry
379        let next_entry = &history.entries[next_index];
380        self.connection
381            .send_command::<_, serde_json::Value>(
382                "Page.navigateToHistoryEntry",
383                Some(
384                    viewpoint_cdp::protocol::page::NavigateToHistoryEntryParams {
385                        entry_id: next_entry.id,
386                    },
387                ),
388                Some(&self.session_id),
389            )
390            .await?;
391
392        info!("Navigated forward to {}", next_entry.url);
393        Ok(Some(NavigationResponse::new(
394            next_entry.url.clone(),
395            self.frame_id.clone(),
396        )))
397    }
398
399    /// Reload the current page.
400    ///
401    /// # Example
402    ///
403    /// ```no_run
404    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
405    /// page.reload().await?;
406    /// # Ok(())
407    /// # }
408    /// ```
409    #[instrument(level = "info", skip(self))]
410    pub async fn reload(&self) -> Result<NavigationResponse, NavigationError> {
411        if self.closed {
412            return Err(NavigationError::Cancelled);
413        }
414
415        info!("Reloading page");
416
417        self.connection
418            .send_command::<_, serde_json::Value>(
419                "Page.reload",
420                Some(viewpoint_cdp::protocol::page::ReloadParams::default()),
421                Some(&self.session_id),
422            )
423            .await?;
424
425        // Get current URL
426        let url = self.url().await.unwrap_or_else(|_| String::new());
427
428        info!("Page reloaded");
429        Ok(NavigationResponse::new(url, self.frame_id.clone()))
430    }
431}