viewpoint_core/page/mod.rs
1//! # Page Management and Interaction
2//!
3//! The `Page` type represents a browser tab and provides methods for navigation,
4//! content interaction, and capturing screenshots or PDFs.
5//!
6//! ## Features
7//!
8//! - **Navigation**: Navigate to URLs, go back/forward, reload
9//! - **Element Interaction**: Locate and interact with elements via [`Locator`]
10//! - **JavaScript Evaluation**: Execute JavaScript in the page context
11//! - **Screenshots**: Capture viewport or full page screenshots
12//! - **PDF Generation**: Generate PDFs from page content
13//! - **Input Devices**: Control keyboard, mouse, and touchscreen
14//! - **Event Handling**: Handle dialogs, downloads, console messages
15//! - **Network Interception**: Route, modify, and mock network requests
16//! - **Clock Mocking**: Control time in the page with [`Clock`]
17//! - **Frames**: Access and interact with iframes via [`Frame`] and [`FrameLocator`]
18//! - **Video Recording**: Record page interactions
19//!
20//! ## Quick Start
21//!
22//! ```no_run
23//! use viewpoint_core::{Browser, DocumentLoadState};
24//! use std::time::Duration;
25//!
26//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
27//! let browser = Browser::launch().headless(true).launch().await?;
28//! let context = browser.new_context().await?;
29//! let page = context.new_page().await?;
30//!
31//! // Navigate to a URL
32//! page.goto("https://example.com")
33//! .wait_until(DocumentLoadState::DomContentLoaded)
34//! .goto()
35//! .await?;
36//!
37//! // Get page title
38//! let title = page.title().await?;
39//! println!("Page title: {}", title);
40//!
41//! // Get current URL
42//! let url = page.url().await?;
43//! println!("Current URL: {}", url);
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! ## Element Interaction with Locators
49//!
50//! ```no_run
51//! use viewpoint_core::{Browser, AriaRole};
52//!
53//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
54//! # let browser = Browser::launch().headless(true).launch().await?;
55//! # let context = browser.new_context().await?;
56//! # let page = context.new_page().await?;
57//! // Click a button
58//! page.locator("button#submit").click().await?;
59//!
60//! // Fill an input
61//! page.locator("input[name='email']").fill("user@example.com").await?;
62//!
63//! // Get text content
64//! let text = page.locator("h1").text_content().await?;
65//!
66//! // Use semantic locators
67//! page.get_by_role(AriaRole::Button)
68//! .with_name("Submit")
69//! .build()
70//! .click()
71//! .await?;
72//!
73//! page.get_by_label("Username").fill("john").await?;
74//! page.get_by_placeholder("Search...").fill("query").await?;
75//! page.get_by_test_id("submit-btn").click().await?;
76//! # Ok(())
77//! # }
78//! ```
79//!
80//! ## Screenshots and PDF
81//!
82//! ```no_run
83//! use viewpoint_core::Browser;
84//! use viewpoint_core::page::PaperFormat;
85//!
86//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
87//! # let browser = Browser::launch().headless(true).launch().await?;
88//! # let context = browser.new_context().await?;
89//! # let page = context.new_page().await?;
90//! // Viewport screenshot
91//! page.screenshot()
92//! .path("screenshot.png")
93//! .capture()
94//! .await?;
95//!
96//! // Full page screenshot
97//! page.screenshot()
98//! .full_page(true)
99//! .path("full-page.png")
100//! .capture()
101//! .await?;
102//!
103//! // Generate PDF
104//! page.pdf()
105//! .format(PaperFormat::A4)
106//! .path("document.pdf")
107//! .generate()
108//! .await?;
109//! # Ok(())
110//! # }
111//! ```
112//!
113//! ## Input Devices
114//!
115//! ```ignore
116//! use viewpoint_core::Browser;
117//!
118//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
119//! # let browser = Browser::launch().headless(true).launch().await?;
120//! # let context = browser.new_context().await?;
121//! # let page = context.new_page().await?;
122//! // Keyboard
123//! page.keyboard().press("Tab").await?;
124//! page.keyboard().type_text("Hello World").await?;
125//! page.keyboard().press("Control+a").await?;
126//!
127//! // Mouse
128//! page.mouse().click(100.0, 200.0).await?;
129//! page.mouse().move_to(300.0, 400.0).await?;
130//!
131//! // Touchscreen
132//! page.touchscreen().tap(100.0, 200.0).await?;
133//! # Ok(())
134//! # }
135//! ```
136//!
137//! ## Event Handling
138//!
139//! ```ignore
140//! use viewpoint_core::Browser;
141//!
142//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
143//! # let browser = Browser::launch().headless(true).launch().await?;
144//! # let context = browser.new_context().await?;
145//! # let page = context.new_page().await?;
146//! // Handle dialogs
147//! page.on_dialog(|dialog| async move {
148//! println!("Dialog: {}", dialog.message());
149//! dialog.accept(None).await
150//! }).await;
151//!
152//! // Handle downloads
153//! page.on_download(|download| async move {
154//! download.save_as("file.zip").await
155//! }).await;
156//!
157//! // Handle console messages
158//! page.on_console(|msg| async move {
159//! println!("[{}] {}", msg.message_type(), msg.text());
160//! Ok(())
161//! }).await;
162//! # Ok(())
163//! # }
164//! ```
165//!
166//! ## Frames
167//!
168//! ```ignore
169//! use viewpoint_core::Browser;
170//!
171//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
172//! # let browser = Browser::launch().headless(true).launch().await?;
173//! # let context = browser.new_context().await?;
174//! # let page = context.new_page().await?;
175//! // Access iframe by selector
176//! let frame = page.frame_locator("iframe#content");
177//! frame.locator("button").click().await?;
178//!
179//! // Access iframe by name
180//! let frame = page.frame("content-frame").await;
181//! if let Some(f) = frame {
182//! f.locator("input").fill("text").await?;
183//! }
184//! # Ok(())
185//! # }
186//! ```
187//!
188//! ## Clock Mocking
189//!
190//! ```ignore
191//! use viewpoint_core::Browser;
192//!
193//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
194//! # let browser = Browser::launch().headless(true).launch().await?;
195//! # let context = browser.new_context().await?;
196//! # let page = context.new_page().await?;
197//! // Install clock mocking
198//! page.clock().install().await?;
199//!
200//! // Set to specific time
201//! page.clock().set_fixed_time("2024-01-01T12:00:00Z").await?;
202//!
203//! // Advance time
204//! page.clock().run_for(60000).await?; // 60 seconds
205//! # Ok(())
206//! # }
207//! ```
208
209mod aria_snapshot;
210pub mod binding;
211mod ref_resolution;
212pub mod clock;
213mod clock_script;
214pub mod console;
215mod constructors;
216mod content;
217pub mod dialog;
218pub mod download;
219pub mod emulation;
220mod evaluate;
221pub mod events;
222pub mod file_chooser;
223pub mod frame;
224pub mod frame_locator;
225mod frame_locator_actions;
226mod frame_page_methods;
227mod input_devices;
228pub mod keyboard;
229pub mod locator;
230mod locator_factory;
231pub mod locator_handler;
232mod mouse;
233mod mouse_drag;
234mod navigation;
235pub mod page_error;
236mod pdf;
237pub mod popup;
238mod routing_impl;
239mod screenshot;
240mod screenshot_element;
241mod scripts;
242mod touchscreen;
243pub mod video;
244mod video_io;
245
246use std::sync::Arc;
247use std::time::Duration;
248
249use tracing::{debug, info, instrument, trace, warn};
250use viewpoint_cdp::CdpConnection;
251use viewpoint_cdp::protocol::page::{NavigateParams, NavigateResult};
252use viewpoint_cdp::protocol::target_domain::CloseTargetParams;
253use viewpoint_js::js;
254
255use crate::error::{NavigationError, PageError};
256use crate::network::{RouteHandlerRegistry, WebSocketManager};
257use crate::wait::{DocumentLoadState, LoadStateWaiter};
258
259pub use clock::{Clock, TimeValue};
260pub use console::{ConsoleMessage, ConsoleMessageLocation, ConsoleMessageType, JsArg};
261pub use content::{ScriptTagBuilder, ScriptType, SetContentBuilder, StyleTagBuilder};
262pub use dialog::Dialog;
263pub use download::{Download, DownloadState};
264pub use emulation::{EmulateMediaBuilder, MediaType, VisionDeficiency};
265pub use evaluate::{JsHandle, Polling, WaitForFunctionBuilder};
266pub use events::PageEventManager;
267pub use file_chooser::{FileChooser, FilePayload};
268pub use frame::Frame;
269pub(crate) use frame::ExecutionContextRegistry;
270pub use frame_locator::{FrameElementLocator, FrameLocator, FrameRoleLocatorBuilder};
271pub use keyboard::Keyboard;
272pub use locator::{
273 AriaCheckedState, AriaRole, AriaSnapshot, BoundingBox, BoxModel, ElementHandle, FilterBuilder,
274 Locator, LocatorOptions, RoleLocatorBuilder, Selector, TapBuilder, TextOptions,
275};
276pub use locator_handler::{LocatorHandlerHandle, LocatorHandlerManager, LocatorHandlerOptions};
277pub use mouse::Mouse;
278pub use mouse_drag::DragAndDropBuilder;
279pub use navigation::{GotoBuilder, NavigationResponse};
280pub use page_error::{PageError as PageErrorInfo, WebError};
281pub use pdf::{Margins, PaperFormat, PdfBuilder};
282pub use screenshot::{Animations, ClipRegion, ScreenshotBuilder, ScreenshotFormat};
283pub use touchscreen::Touchscreen;
284pub use video::{Video, VideoOptions};
285pub use viewpoint_cdp::protocol::DialogType;
286pub use viewpoint_cdp::protocol::emulation::ViewportSize;
287pub use viewpoint_cdp::protocol::input::MouseButton;
288
289/// Default navigation timeout.
290const DEFAULT_NAVIGATION_TIMEOUT: Duration = Duration::from_secs(30);
291
292/// Default test ID attribute name.
293pub const DEFAULT_TEST_ID_ATTRIBUTE: &str = "data-testid";
294
295/// A browser page (tab).
296pub struct Page {
297 /// CDP connection.
298 connection: Arc<CdpConnection>,
299 /// Target ID.
300 target_id: String,
301 /// Session ID for this page.
302 session_id: String,
303 /// Main frame ID.
304 frame_id: String,
305 /// Whether the page has been closed.
306 closed: bool,
307 /// Route handler registry.
308 route_registry: Arc<RouteHandlerRegistry>,
309 /// Keyboard controller.
310 keyboard: Keyboard,
311 /// Mouse controller.
312 mouse: Mouse,
313 /// Touchscreen controller.
314 touchscreen: Touchscreen,
315 /// Event manager for dialogs, downloads, and file choosers.
316 event_manager: Arc<PageEventManager>,
317 /// Locator handler manager.
318 locator_handler_manager: Arc<LocatorHandlerManager>,
319 /// Video recording controller (if recording is enabled).
320 video_controller: Option<Arc<Video>>,
321 /// Opener target ID (for popup pages).
322 opener_target_id: Option<String>,
323 /// Popup event manager.
324 popup_manager: Arc<popup::PopupManager>,
325 /// WebSocket event manager.
326 websocket_manager: Arc<WebSocketManager>,
327 /// Exposed function binding manager.
328 binding_manager: Arc<binding::BindingManager>,
329 /// Custom test ID attribute (defaults to "data-testid").
330 test_id_attribute: String,
331 /// Execution context registry for tracking frame contexts.
332 context_registry: Arc<ExecutionContextRegistry>,
333}
334
335// Manual Debug implementation since some fields don't implement Debug
336impl std::fmt::Debug for Page {
337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338 f.debug_struct("Page")
339 .field("target_id", &self.target_id)
340 .field("session_id", &self.session_id)
341 .field("frame_id", &self.frame_id)
342 .field("closed", &self.closed)
343 .finish_non_exhaustive()
344 }
345}
346
347impl Page {
348 /// Navigate to a URL.
349 ///
350 /// Returns a builder for configuring navigation options.
351 ///
352 /// # Example
353 ///
354 /// ```no_run
355 /// use viewpoint_core::Page;
356 /// use viewpoint_core::DocumentLoadState;
357 /// use std::time::Duration;
358 ///
359 /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
360 /// // Simple navigation
361 /// page.goto("https://example.com").goto().await?;
362 ///
363 /// // Navigation with options
364 /// page.goto("https://example.com")
365 /// .wait_until(DocumentLoadState::DomContentLoaded)
366 /// .timeout(Duration::from_secs(10))
367 /// .goto()
368 /// .await?;
369 /// # Ok(())
370 /// # }
371 /// ```
372 pub fn goto(&self, url: impl Into<String>) -> GotoBuilder<'_> {
373 GotoBuilder::new(self, url.into())
374 }
375
376 /// Navigate to a URL and wait for the specified load state.
377 ///
378 /// This is a convenience method that calls `goto(url).goto().await`.
379 ///
380 /// # Errors
381 ///
382 /// Returns an error if:
383 /// - The page is closed
384 /// - Navigation fails
385 /// - The wait times out
386 pub async fn goto_url(&self, url: &str) -> Result<NavigationResponse, NavigationError> {
387 self.goto(url).goto().await
388 }
389
390 /// Navigate to a URL with the given options.
391 #[instrument(level = "info", skip(self), fields(target_id = %self.target_id, url = %url, wait_until = ?wait_until, timeout_ms = timeout.as_millis()))]
392 pub(crate) async fn navigate_internal(
393 &self,
394 url: &str,
395 wait_until: DocumentLoadState,
396 timeout: Duration,
397 referer: Option<&str>,
398 ) -> Result<NavigationResponse, NavigationError> {
399 if self.closed {
400 warn!("Attempted navigation on closed page");
401 return Err(NavigationError::Cancelled);
402 }
403
404 info!("Starting navigation");
405
406 // Create a load state waiter
407 let event_rx = self.connection.subscribe_events();
408 let mut waiter =
409 LoadStateWaiter::new(event_rx, self.session_id.clone(), self.frame_id.clone());
410 trace!("Created load state waiter");
411
412 // Send the navigation command
413 debug!("Sending Page.navigate command");
414 let result: NavigateResult = self
415 .connection
416 .send_command(
417 "Page.navigate",
418 Some(NavigateParams {
419 url: url.to_string(),
420 referrer: referer.map(ToString::to_string),
421 transition_type: None,
422 frame_id: None,
423 }),
424 Some(&self.session_id),
425 )
426 .await?;
427
428 debug!(frame_id = %result.frame_id, loader_id = ?result.loader_id, "Page.navigate completed");
429
430 // Check for navigation errors
431 // Note: Chrome reports HTTP error status codes (4xx, 5xx) as errors with
432 // "net::ERR_HTTP_RESPONSE_CODE_FAILURE" or "net::ERR_INVALID_AUTH_CREDENTIALS".
433 // Following Playwright's behavior, we treat these as successful navigations
434 // that return a response with the appropriate status code.
435 if let Some(ref error_text) = result.error_text {
436 let is_http_error = error_text == "net::ERR_HTTP_RESPONSE_CODE_FAILURE"
437 || error_text == "net::ERR_INVALID_AUTH_CREDENTIALS";
438
439 if !is_http_error {
440 warn!(error = %error_text, "Navigation failed with error");
441 return Err(NavigationError::NetworkError(error_text.clone()));
442 }
443 debug!(error = %error_text, "HTTP error response - continuing to capture status");
444 }
445
446 // Mark commit as received
447 trace!("Setting commit received");
448 waiter.set_commit_received().await;
449
450 // Wait for the target load state
451 debug!(wait_until = ?wait_until, "Waiting for load state");
452 waiter
453 .wait_for_load_state_with_timeout(wait_until, timeout)
454 .await?;
455
456 // Get response data captured during navigation
457 let response_data = waiter.response_data().await;
458
459 info!(frame_id = %result.frame_id, "Navigation completed successfully");
460
461 // Use the final URL from response data if available (handles redirects)
462 let final_url = response_data.url.unwrap_or_else(|| url.to_string());
463
464 // Build the response with captured data
465 if let Some(status) = response_data.status {
466 Ok(NavigationResponse::with_response(
467 final_url,
468 result.frame_id,
469 status,
470 response_data.headers,
471 ))
472 } else {
473 Ok(NavigationResponse::new(final_url, result.frame_id))
474 }
475 }
476
477 /// Close this page.
478 ///
479 /// # Errors
480 ///
481 /// Returns an error if closing fails.
482 #[instrument(level = "info", skip(self), fields(target_id = %self.target_id))]
483 pub async fn close(&mut self) -> Result<(), PageError> {
484 if self.closed {
485 debug!("Page already closed");
486 return Ok(());
487 }
488
489 info!("Closing page");
490
491 // Clean up route handlers
492 self.route_registry.unroute_all().await;
493 debug!("Route handlers cleaned up");
494
495 self.connection
496 .send_command::<_, serde_json::Value>(
497 "Target.closeTarget",
498 Some(CloseTargetParams {
499 target_id: self.target_id.clone(),
500 }),
501 None,
502 )
503 .await?;
504
505 self.closed = true;
506 info!("Page closed");
507 Ok(())
508 }
509
510 /// Get the target ID.
511 pub fn target_id(&self) -> &str {
512 &self.target_id
513 }
514
515 /// Get the session ID.
516 pub fn session_id(&self) -> &str {
517 &self.session_id
518 }
519
520 /// Get the main frame ID.
521 pub fn frame_id(&self) -> &str {
522 &self.frame_id
523 }
524
525 /// Check if this page has been closed.
526 pub fn is_closed(&self) -> bool {
527 self.closed
528 }
529
530 /// Get a reference to the CDP connection.
531 pub fn connection(&self) -> &Arc<CdpConnection> {
532 &self.connection
533 }
534
535 // =========================================================================
536 // Screenshot & PDF Methods
537 // =========================================================================
538
539 /// Create a screenshot builder for capturing page screenshots.
540 ///
541 /// # Example
542 ///
543 /// ```no_run
544 /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
545 /// // Capture viewport screenshot
546 /// let bytes = page.screenshot().capture().await?;
547 ///
548 /// // Capture full page screenshot
549 /// page.screenshot()
550 /// .full_page(true)
551 /// .path("screenshot.png")
552 /// .capture()
553 /// .await?;
554 ///
555 /// // Capture JPEG with quality
556 /// page.screenshot()
557 /// .jpeg(Some(80))
558 /// .path("screenshot.jpg")
559 /// .capture()
560 /// .await?;
561 /// # Ok(())
562 /// # }
563 /// ```
564 pub fn screenshot(&self) -> screenshot::ScreenshotBuilder<'_> {
565 screenshot::ScreenshotBuilder::new(self)
566 }
567
568 /// Create a PDF builder for generating PDFs from the page.
569 ///
570 /// # Example
571 ///
572 /// ```no_run
573 /// use viewpoint_core::page::PaperFormat;
574 ///
575 /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
576 /// // Generate PDF with default settings
577 /// let bytes = page.pdf().generate().await?;
578 ///
579 /// // Generate A4 landscape PDF
580 /// page.pdf()
581 /// .format(PaperFormat::A4)
582 /// .landscape(true)
583 /// .path("document.pdf")
584 /// .generate()
585 /// .await?;
586 ///
587 /// // Generate PDF with custom margins
588 /// page.pdf()
589 /// .margin(1.0) // 1 inch margins
590 /// .print_background(true)
591 /// .generate()
592 /// .await?;
593 /// # Ok(())
594 /// # }
595 /// ```
596 pub fn pdf(&self) -> pdf::PdfBuilder<'_> {
597 pdf::PdfBuilder::new(self)
598 }
599
600 /// Get the current page URL.
601 ///
602 /// # Errors
603 ///
604 /// Returns an error if the page is closed or the evaluation fails.
605 pub async fn url(&self) -> Result<String, PageError> {
606 if self.closed {
607 return Err(PageError::Closed);
608 }
609
610 let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
611 .connection
612 .send_command(
613 "Runtime.evaluate",
614 Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
615 expression: js! { window.location.href }.to_string(),
616 object_group: None,
617 include_command_line_api: None,
618 silent: Some(true),
619 context_id: None,
620 return_by_value: Some(true),
621 await_promise: Some(false),
622 }),
623 Some(&self.session_id),
624 )
625 .await?;
626
627 result
628 .result
629 .value
630 .and_then(|v| v.as_str().map(std::string::ToString::to_string))
631 .ok_or_else(|| PageError::EvaluationFailed("Failed to get URL".to_string()))
632 }
633
634 /// Get the current page title.
635 ///
636 /// # Errors
637 ///
638 /// Returns an error if the page is closed or the evaluation fails.
639 pub async fn title(&self) -> Result<String, PageError> {
640 if self.closed {
641 return Err(PageError::Closed);
642 }
643
644 let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
645 .connection
646 .send_command(
647 "Runtime.evaluate",
648 Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
649 expression: "document.title".to_string(),
650 object_group: None,
651 include_command_line_api: None,
652 silent: Some(true),
653 context_id: None,
654 return_by_value: Some(true),
655 await_promise: Some(false),
656 }),
657 Some(&self.session_id),
658 )
659 .await?;
660
661 result
662 .result
663 .value
664 .and_then(|v| v.as_str().map(std::string::ToString::to_string))
665 .ok_or_else(|| PageError::EvaluationFailed("Failed to get title".to_string()))
666 }
667}
668
669// Additional Page methods are defined in:
670// - scripts.rs: add_init_script, add_init_script_path
671// - locator_handler.rs: add_locator_handler, add_locator_handler_with_options, remove_locator_handler
672// - video.rs: video, start_video_recording, stop_video_recording
673// - binding.rs: expose_function, remove_exposed_function
674// - input_devices.rs: keyboard, mouse, touchscreen, clock, drag_and_drop
675// - locator_factory.rs: locator, get_by_*, set_test_id_attribute
676// - DragAndDropBuilder is defined in mouse.rs
677// - RoleLocatorBuilder is defined in locator/mod.rs