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::{DEFAULT_NAVIGATION_TIMEOUT, Page};
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(
189 &self.url,
190 self.wait_until,
191 self.timeout,
192 self.referer.as_deref(),
193 )
194 .await
195 }
196}
197
198// =============================================================================
199// Navigation History Methods (impl extension for Page)
200// =============================================================================
201
202impl Page {
203 /// Navigate back in history.
204 ///
205 /// Returns `None` if there is no previous page in history.
206 ///
207 /// # Example
208 ///
209 /// ```no_run
210 /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
211 /// if page.go_back().await?.is_some() {
212 /// println!("Navigated back");
213 /// }
214 /// # Ok(())
215 /// # }
216 /// ```
217 #[instrument(level = "info", skip(self))]
218 pub async fn go_back(&self) -> Result<Option<NavigationResponse>, NavigationError> {
219 if self.closed {
220 return Err(NavigationError::Cancelled);
221 }
222
223 // Check if we can go back
224 let history: viewpoint_cdp::protocol::page::GetNavigationHistoryResult = self
225 .connection
226 .send_command(
227 "Page.getNavigationHistory",
228 None::<()>,
229 Some(&self.session_id),
230 )
231 .await?;
232
233 if history.current_index <= 0 {
234 debug!("No previous page in history");
235 return Ok(None);
236 }
237
238 // Navigate to the previous entry
239 let previous_entry = &history.entries[history.current_index as usize - 1];
240 self.connection
241 .send_command::<_, serde_json::Value>(
242 "Page.navigateToHistoryEntry",
243 Some(
244 viewpoint_cdp::protocol::page::NavigateToHistoryEntryParams {
245 entry_id: previous_entry.id,
246 },
247 ),
248 Some(&self.session_id),
249 )
250 .await?;
251
252 info!("Navigated back to {}", previous_entry.url);
253 Ok(Some(NavigationResponse::new(
254 previous_entry.url.clone(),
255 self.frame_id.clone(),
256 )))
257 }
258
259 /// Navigate forward in history.
260 ///
261 /// Returns `None` if there is no next page in history.
262 #[instrument(level = "info", skip(self))]
263 pub async fn go_forward(&self) -> Result<Option<NavigationResponse>, NavigationError> {
264 if self.closed {
265 return Err(NavigationError::Cancelled);
266 }
267
268 // Check if we can go forward
269 let history: viewpoint_cdp::protocol::page::GetNavigationHistoryResult = self
270 .connection
271 .send_command(
272 "Page.getNavigationHistory",
273 None::<()>,
274 Some(&self.session_id),
275 )
276 .await?;
277
278 let next_index = history.current_index as usize + 1;
279 if next_index >= history.entries.len() {
280 debug!("No next page in history");
281 return Ok(None);
282 }
283
284 // Navigate to the next entry
285 let next_entry = &history.entries[next_index];
286 self.connection
287 .send_command::<_, serde_json::Value>(
288 "Page.navigateToHistoryEntry",
289 Some(
290 viewpoint_cdp::protocol::page::NavigateToHistoryEntryParams {
291 entry_id: next_entry.id,
292 },
293 ),
294 Some(&self.session_id),
295 )
296 .await?;
297
298 info!("Navigated forward to {}", next_entry.url);
299 Ok(Some(NavigationResponse::new(
300 next_entry.url.clone(),
301 self.frame_id.clone(),
302 )))
303 }
304
305 /// Reload the current page.
306 ///
307 /// # Example
308 ///
309 /// ```no_run
310 /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
311 /// page.reload().await?;
312 /// # Ok(())
313 /// # }
314 /// ```
315 #[instrument(level = "info", skip(self))]
316 pub async fn reload(&self) -> Result<NavigationResponse, NavigationError> {
317 if self.closed {
318 return Err(NavigationError::Cancelled);
319 }
320
321 info!("Reloading page");
322
323 self.connection
324 .send_command::<_, serde_json::Value>(
325 "Page.reload",
326 Some(viewpoint_cdp::protocol::page::ReloadParams::default()),
327 Some(&self.session_id),
328 )
329 .await?;
330
331 // Get current URL
332 let url = self.url().await.unwrap_or_else(|_| String::new());
333
334 info!("Page reloaded");
335 Ok(NavigationResponse::new(url, self.frame_id.clone()))
336 }
337}