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}