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};
7
8use crate::error::NavigationError;
9use crate::wait::DocumentLoadState;
10
11use super::{Page, DEFAULT_NAVIGATION_TIMEOUT};
12
13/// Response from a navigation.
14#[derive(Debug, Clone)]
15pub struct NavigationResponse {
16    /// The URL that was navigated to (final URL after redirects).
17    url: String,
18    /// The frame ID that navigated.
19    frame_id: String,
20    /// HTTP status code of the response (e.g., 200, 404, 302).
21    status: Option<u16>,
22    /// HTTP response headers.
23    headers: Option<HashMap<String, String>>,
24}
25
26impl NavigationResponse {
27    /// Create a new navigation response.
28    pub(crate) fn new(url: String, frame_id: String) -> Self {
29        Self {
30            url,
31            frame_id,
32            status: None,
33            headers: None,
34        }
35    }
36
37    /// Create a navigation response with response data.
38    pub(crate) fn with_response(
39        url: String,
40        frame_id: String,
41        status: u16,
42        headers: HashMap<String, String>,
43    ) -> Self {
44        Self {
45            url,
46            frame_id,
47            status: Some(status),
48            headers: Some(headers),
49        }
50    }
51
52    /// Get the final URL after any redirects.
53    pub fn url(&self) -> &str {
54        &self.url
55    }
56
57    /// Get the frame ID that navigated.
58    pub fn frame_id(&self) -> &str {
59        &self.frame_id
60    }
61
62    /// Get the HTTP status code of the final response.
63    ///
64    /// Returns `None` if status was not captured (e.g., for `about:blank`).
65    pub fn status(&self) -> Option<u16> {
66        self.status
67    }
68
69    /// Get the HTTP response headers.
70    ///
71    /// Returns `None` if headers were not captured.
72    pub fn headers(&self) -> Option<&HashMap<String, String>> {
73        self.headers.as_ref()
74    }
75
76    /// Check if the navigation resulted in an OK response (2xx status).
77    pub fn ok(&self) -> bool {
78        self.status.is_none_or(|s| (200..300).contains(&s))
79    }
80}
81
82/// Builder for configuring page navigation.
83#[derive(Debug)]
84pub struct GotoBuilder<'a> {
85    page: &'a Page,
86    url: String,
87    wait_until: DocumentLoadState,
88    timeout: Duration,
89    referer: Option<String>,
90}
91
92impl<'a> GotoBuilder<'a> {
93    /// Create a new navigation builder.
94    pub(crate) fn new(page: &'a Page, url: String) -> Self {
95        Self {
96            page,
97            url,
98            wait_until: DocumentLoadState::default(),
99            timeout: DEFAULT_NAVIGATION_TIMEOUT,
100            referer: None,
101        }
102    }
103
104    /// Set the load state to wait for.
105    ///
106    /// Default is `DocumentLoadState::Load`.
107    ///
108    /// # Example
109    ///
110    /// ```no_run
111    /// use viewpoint_core::DocumentLoadState;
112    ///
113    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
114    /// // Wait only for DOM content loaded (faster)
115    /// page.goto("https://example.com")
116    ///     .wait_until(DocumentLoadState::DomContentLoaded)
117    ///     .goto()
118    ///     .await?;
119    ///
120    /// // Wait for network idle (slower but more complete)
121    /// page.goto("https://example.com")
122    ///     .wait_until(DocumentLoadState::NetworkIdle)
123    ///     .goto()
124    ///     .await?;
125    /// # Ok(())
126    /// # }
127    /// ```
128    #[must_use]
129    pub fn wait_until(mut self, state: DocumentLoadState) -> Self {
130        self.wait_until = state;
131        self
132    }
133
134    /// Set the navigation timeout.
135    ///
136    /// Default is 30 seconds.
137    ///
138    /// # Example
139    ///
140    /// ```no_run
141    /// use std::time::Duration;
142    ///
143    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
144    /// page.goto("https://slow-site.com")
145    ///     .timeout(Duration::from_secs(60))
146    ///     .goto()
147    ///     .await?;
148    /// # Ok(())
149    /// # }
150    /// ```
151    #[must_use]
152    pub fn timeout(mut self, timeout: Duration) -> Self {
153        self.timeout = timeout;
154        self
155    }
156
157    /// Set the referer header for the navigation.
158    ///
159    /// # Example
160    ///
161    /// ```no_run
162    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
163    /// page.goto("https://example.com")
164    ///     .referer("https://google.com")
165    ///     .goto()
166    ///     .await?;
167    /// # Ok(())
168    /// # }
169    /// ```
170    #[must_use]
171    pub fn referer(mut self, referer: impl Into<String>) -> Self {
172        self.referer = Some(referer.into());
173        self
174    }
175
176    /// Execute the navigation.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if:
181    /// - The page is closed
182    /// - Navigation fails (network error, SSL error, etc.)
183    /// - The wait times out
184    #[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()))]
185    pub async fn goto(self) -> Result<NavigationResponse, NavigationError> {
186        debug!("Executing navigation via GotoBuilder");
187        self.page
188            .navigate_internal(&self.url, self.wait_until, self.timeout, self.referer.as_deref())
189            .await
190    }
191}
192
193// =============================================================================
194// Navigation History Methods (impl extension for Page)
195// =============================================================================
196
197impl Page {
198    /// Navigate back in history.
199    ///
200    /// Returns `None` if there is no previous page in history.
201    ///
202    /// # Example
203    ///
204    /// ```no_run
205    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
206    /// if page.go_back().await?.is_some() {
207    ///     println!("Navigated back");
208    /// }
209    /// # Ok(())
210    /// # }
211    /// ```
212    #[instrument(level = "info", skip(self))]
213    pub async fn go_back(&self) -> Result<Option<NavigationResponse>, NavigationError> {
214        if self.closed {
215            return Err(NavigationError::Cancelled);
216        }
217
218        // Check if we can go back
219        let history: viewpoint_cdp::protocol::page::GetNavigationHistoryResult = self
220            .connection
221            .send_command("Page.getNavigationHistory", None::<()>, Some(&self.session_id))
222            .await?;
223
224        if history.current_index <= 0 {
225            debug!("No previous page in history");
226            return Ok(None);
227        }
228
229        // Navigate to the previous entry
230        let previous_entry = &history.entries[history.current_index as usize - 1];
231        self.connection
232            .send_command::<_, serde_json::Value>(
233                "Page.navigateToHistoryEntry",
234                Some(viewpoint_cdp::protocol::page::NavigateToHistoryEntryParams {
235                    entry_id: previous_entry.id,
236                }),
237                Some(&self.session_id),
238            )
239            .await?;
240
241        info!("Navigated back to {}", previous_entry.url);
242        Ok(Some(NavigationResponse::new(previous_entry.url.clone(), self.frame_id.clone())))
243    }
244
245    /// Navigate forward in history.
246    ///
247    /// Returns `None` if there is no next page in history.
248    #[instrument(level = "info", skip(self))]
249    pub async fn go_forward(&self) -> Result<Option<NavigationResponse>, NavigationError> {
250        if self.closed {
251            return Err(NavigationError::Cancelled);
252        }
253
254        // Check if we can go forward
255        let history: viewpoint_cdp::protocol::page::GetNavigationHistoryResult = self
256            .connection
257            .send_command("Page.getNavigationHistory", None::<()>, Some(&self.session_id))
258            .await?;
259
260        let next_index = history.current_index as usize + 1;
261        if next_index >= history.entries.len() {
262            debug!("No next page in history");
263            return Ok(None);
264        }
265
266        // Navigate to the next entry
267        let next_entry = &history.entries[next_index];
268        self.connection
269            .send_command::<_, serde_json::Value>(
270                "Page.navigateToHistoryEntry",
271                Some(viewpoint_cdp::protocol::page::NavigateToHistoryEntryParams {
272                    entry_id: next_entry.id,
273                }),
274                Some(&self.session_id),
275            )
276            .await?;
277
278        info!("Navigated forward to {}", next_entry.url);
279        Ok(Some(NavigationResponse::new(next_entry.url.clone(), self.frame_id.clone())))
280    }
281
282    /// Reload the current page.
283    ///
284    /// # Example
285    ///
286    /// ```no_run
287    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
288    /// page.reload().await?;
289    /// # Ok(())
290    /// # }
291    /// ```
292    #[instrument(level = "info", skip(self))]
293    pub async fn reload(&self) -> Result<NavigationResponse, NavigationError> {
294        if self.closed {
295            return Err(NavigationError::Cancelled);
296        }
297
298        info!("Reloading page");
299
300        self.connection
301            .send_command::<_, serde_json::Value>(
302                "Page.reload",
303                Some(viewpoint_cdp::protocol::page::ReloadParams::default()),
304                Some(&self.session_id),
305            )
306            .await?;
307
308        // Get current URL
309        let url = self.url().await.unwrap_or_else(|_| String::new());
310
311        info!("Page reloaded");
312        Ok(NavigationResponse::new(url, self.frame_id.clone()))
313    }
314}