viewpoint_core/page/navigation/
mod.rs1use 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#[derive(Debug, Clone)]
16pub struct NavigationResponse {
17 url: String,
19 frame_id: String,
21 status: Option<u16>,
23 headers: Option<HashMap<String, String>>,
25}
26
27impl NavigationResponse {
28 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 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 pub fn url(&self) -> &str {
55 &self.url
56 }
57
58 pub fn frame_id(&self) -> &str {
60 &self.frame_id
61 }
62
63 pub fn status(&self) -> Option<u16> {
67 self.status
68 }
69
70 pub fn headers(&self) -> Option<&HashMap<String, String>> {
74 self.headers.as_ref()
75 }
76
77 pub fn ok(&self) -> bool {
79 self.status.is_none_or(|s| (200..300).contains(&s))
80 }
81}
82
83#[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 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 #[must_use]
130 pub fn wait_until(mut self, state: DocumentLoadState) -> Self {
131 self.wait_until = state;
132 self
133 }
134
135 #[must_use]
153 pub fn timeout(mut self, timeout: Duration) -> Self {
154 self.timeout = timeout;
155 self
156 }
157
158 #[must_use]
172 pub fn referer(mut self, referer: impl Into<String>) -> Self {
173 self.referer = Some(referer.into());
174 self
175 }
176
177 #[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
199impl Page {
204 #[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 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 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 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 trace!("Setting commit received");
262 waiter.set_commit_received().await;
263
264 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 let response_data = waiter.response_data().await;
272
273 info!(frame_id = %result.frame_id, "Navigation completed successfully");
274
275 let final_url = response_data.url.unwrap_or_else(|| url.to_string());
277
278 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
292impl Page {
297 #[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 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 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 #[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 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 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 #[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 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}