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 use aria_snapshot::SnapshotOptions;
211pub mod binding;
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 ref_resolution;
239mod routing_impl;
240mod screenshot;
241mod screenshot_element;
242mod scripts;
243mod touchscreen;
244pub mod video;
245mod video_io;
246
247use std::sync::Arc;
248use std::time::Duration;
249
250use tracing::{debug, info, instrument, trace, warn};
251use viewpoint_cdp::CdpConnection;
252use viewpoint_cdp::protocol::page::{NavigateParams, NavigateResult};
253use viewpoint_cdp::protocol::target_domain::CloseTargetParams;
254use viewpoint_js::js;
255
256use crate::error::{NavigationError, PageError};
257use crate::network::{RouteHandlerRegistry, WebSocketManager};
258use crate::wait::{DocumentLoadState, LoadStateWaiter};
259
260pub use clock::{Clock, TimeValue};
261pub use console::{ConsoleMessage, ConsoleMessageLocation, ConsoleMessageType, JsArg};
262pub use content::{ScriptTagBuilder, ScriptType, SetContentBuilder, StyleTagBuilder};
263pub use dialog::Dialog;
264pub use download::{Download, DownloadState};
265pub use emulation::{EmulateMediaBuilder, MediaType, VisionDeficiency};
266pub use evaluate::{JsHandle, Polling, WaitForFunctionBuilder};
267pub use events::PageEventManager;
268pub use file_chooser::{FileChooser, FilePayload};
269pub(crate) use frame::ExecutionContextRegistry;
270pub use frame::Frame;
271pub use frame_locator::{FrameElementLocator, FrameLocator, FrameRoleLocatorBuilder};
272pub use keyboard::Keyboard;
273pub use locator::{
274 AriaCheckedState, AriaRole, AriaSnapshot, BoundingBox, BoxModel, ElementHandle, FilterBuilder,
275 Locator, LocatorOptions, RoleLocatorBuilder, Selector, TapBuilder, TextOptions,
276};
277pub use locator_handler::{LocatorHandlerHandle, LocatorHandlerManager, LocatorHandlerOptions};
278pub use mouse::Mouse;
279pub use mouse_drag::DragAndDropBuilder;
280pub use navigation::{GotoBuilder, NavigationResponse};
281pub use page_error::{PageError as PageErrorInfo, WebError};
282pub use pdf::{Margins, PaperFormat, PdfBuilder};
283pub use screenshot::{Animations, ClipRegion, ScreenshotBuilder, ScreenshotFormat};
284pub use touchscreen::Touchscreen;
285pub use video::{Video, VideoOptions};
286pub use viewpoint_cdp::protocol::DialogType;
287pub use viewpoint_cdp::protocol::emulation::ViewportSize;
288pub use viewpoint_cdp::protocol::input::MouseButton;
289
290/// Default navigation timeout.
291const DEFAULT_NAVIGATION_TIMEOUT: Duration = Duration::from_secs(30);
292
293/// Default test ID attribute name.
294pub const DEFAULT_TEST_ID_ATTRIBUTE: &str = "data-testid";
295
296/// A browser page (tab).
297pub struct Page {
298 /// CDP connection.
299 connection: Arc<CdpConnection>,
300 /// Target ID.
301 target_id: String,
302 /// Session ID for this page.
303 session_id: String,
304 /// Main frame ID.
305 frame_id: String,
306 /// Whether the page has been closed.
307 closed: bool,
308 /// Route handler registry.
309 route_registry: Arc<RouteHandlerRegistry>,
310 /// Keyboard controller.
311 keyboard: Keyboard,
312 /// Mouse controller.
313 mouse: Mouse,
314 /// Touchscreen controller.
315 touchscreen: Touchscreen,
316 /// Event manager for dialogs, downloads, and file choosers.
317 event_manager: Arc<PageEventManager>,
318 /// Locator handler manager.
319 locator_handler_manager: Arc<LocatorHandlerManager>,
320 /// Video recording controller (if recording is enabled).
321 video_controller: Option<Arc<Video>>,
322 /// Opener target ID (for popup pages).
323 opener_target_id: Option<String>,
324 /// Popup event manager.
325 popup_manager: Arc<popup::PopupManager>,
326 /// WebSocket event manager.
327 websocket_manager: Arc<WebSocketManager>,
328 /// Exposed function binding manager.
329 binding_manager: Arc<binding::BindingManager>,
330 /// Custom test ID attribute (defaults to "data-testid").
331 test_id_attribute: String,
332 /// Execution context registry for tracking frame contexts.
333 context_registry: Arc<ExecutionContextRegistry>,
334}
335
336// Manual Debug implementation since some fields don't implement Debug
337impl std::fmt::Debug for Page {
338 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339 f.debug_struct("Page")
340 .field("target_id", &self.target_id)
341 .field("session_id", &self.session_id)
342 .field("frame_id", &self.frame_id)
343 .field("closed", &self.closed)
344 .finish_non_exhaustive()
345 }
346}
347
348impl Page {
349 /// Navigate to a URL.
350 ///
351 /// Returns a builder for configuring navigation options.
352 ///
353 /// # Example
354 ///
355 /// ```no_run
356 /// use viewpoint_core::Page;
357 /// use viewpoint_core::DocumentLoadState;
358 /// use std::time::Duration;
359 ///
360 /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
361 /// // Simple navigation
362 /// page.goto("https://example.com").goto().await?;
363 ///
364 /// // Navigation with options
365 /// page.goto("https://example.com")
366 /// .wait_until(DocumentLoadState::DomContentLoaded)
367 /// .timeout(Duration::from_secs(10))
368 /// .goto()
369 /// .await?;
370 /// # Ok(())
371 /// # }
372 /// ```
373 pub fn goto(&self, url: impl Into<String>) -> GotoBuilder<'_> {
374 GotoBuilder::new(self, url.into())
375 }
376
377 /// Navigate to a URL and wait for the specified load state.
378 ///
379 /// This is a convenience method that calls `goto(url).goto().await`.
380 ///
381 /// # Errors
382 ///
383 /// Returns an error if:
384 /// - The page is closed
385 /// - Navigation fails
386 /// - The wait times out
387 pub async fn goto_url(&self, url: &str) -> Result<NavigationResponse, NavigationError> {
388 self.goto(url).goto().await
389 }
390
391 /// Navigate to a URL with the given options.
392 #[instrument(level = "info", skip(self), fields(target_id = %self.target_id, url = %url, wait_until = ?wait_until, timeout_ms = timeout.as_millis()))]
393 pub(crate) async fn navigate_internal(
394 &self,
395 url: &str,
396 wait_until: DocumentLoadState,
397 timeout: Duration,
398 referer: Option<&str>,
399 ) -> Result<NavigationResponse, NavigationError> {
400 if self.closed {
401 warn!("Attempted navigation on closed page");
402 return Err(NavigationError::Cancelled);
403 }
404
405 info!("Starting navigation");
406
407 // Create a load state waiter
408 let event_rx = self.connection.subscribe_events();
409 let mut waiter =
410 LoadStateWaiter::new(event_rx, self.session_id.clone(), self.frame_id.clone());
411 trace!("Created load state waiter");
412
413 // Send the navigation command
414 debug!("Sending Page.navigate command");
415 let result: NavigateResult = self
416 .connection
417 .send_command(
418 "Page.navigate",
419 Some(NavigateParams {
420 url: url.to_string(),
421 referrer: referer.map(ToString::to_string),
422 transition_type: None,
423 frame_id: None,
424 }),
425 Some(&self.session_id),
426 )
427 .await?;
428
429 debug!(frame_id = %result.frame_id, loader_id = ?result.loader_id, "Page.navigate completed");
430
431 // Check for navigation errors
432 // Note: Chrome reports HTTP error status codes (4xx, 5xx) as errors with
433 // "net::ERR_HTTP_RESPONSE_CODE_FAILURE" or "net::ERR_INVALID_AUTH_CREDENTIALS".
434 // Following Playwright's behavior, we treat these as successful navigations
435 // that return a response with the appropriate status code.
436 if let Some(ref error_text) = result.error_text {
437 let is_http_error = error_text == "net::ERR_HTTP_RESPONSE_CODE_FAILURE"
438 || error_text == "net::ERR_INVALID_AUTH_CREDENTIALS";
439
440 if !is_http_error {
441 warn!(error = %error_text, "Navigation failed with error");
442 return Err(NavigationError::NetworkError(error_text.clone()));
443 }
444 debug!(error = %error_text, "HTTP error response - continuing to capture status");
445 }
446
447 // Mark commit as received
448 trace!("Setting commit received");
449 waiter.set_commit_received().await;
450
451 // Wait for the target load state
452 debug!(wait_until = ?wait_until, "Waiting for load state");
453 waiter
454 .wait_for_load_state_with_timeout(wait_until, timeout)
455 .await?;
456
457 // Get response data captured during navigation
458 let response_data = waiter.response_data().await;
459
460 info!(frame_id = %result.frame_id, "Navigation completed successfully");
461
462 // Use the final URL from response data if available (handles redirects)
463 let final_url = response_data.url.unwrap_or_else(|| url.to_string());
464
465 // Build the response with captured data
466 if let Some(status) = response_data.status {
467 Ok(NavigationResponse::with_response(
468 final_url,
469 result.frame_id,
470 status,
471 response_data.headers,
472 ))
473 } else {
474 Ok(NavigationResponse::new(final_url, result.frame_id))
475 }
476 }
477
478 /// Close this page.
479 ///
480 /// # Errors
481 ///
482 /// Returns an error if closing fails.
483 #[instrument(level = "info", skip(self), fields(target_id = %self.target_id))]
484 pub async fn close(&mut self) -> Result<(), PageError> {
485 if self.closed {
486 debug!("Page already closed");
487 return Ok(());
488 }
489
490 info!("Closing page");
491
492 // Clean up route handlers
493 self.route_registry.unroute_all().await;
494 debug!("Route handlers cleaned up");
495
496 self.connection
497 .send_command::<_, serde_json::Value>(
498 "Target.closeTarget",
499 Some(CloseTargetParams {
500 target_id: self.target_id.clone(),
501 }),
502 None,
503 )
504 .await?;
505
506 self.closed = true;
507 info!("Page closed");
508 Ok(())
509 }
510
511 /// Get the target ID.
512 pub fn target_id(&self) -> &str {
513 &self.target_id
514 }
515
516 /// Get the session ID.
517 pub fn session_id(&self) -> &str {
518 &self.session_id
519 }
520
521 /// Get the main frame ID.
522 pub fn frame_id(&self) -> &str {
523 &self.frame_id
524 }
525
526 /// Check if this page has been closed.
527 pub fn is_closed(&self) -> bool {
528 self.closed
529 }
530
531 /// Get a reference to the CDP connection.
532 pub fn connection(&self) -> &Arc<CdpConnection> {
533 &self.connection
534 }
535
536 // =========================================================================
537 // Screenshot & PDF Methods
538 // =========================================================================
539
540 /// Create a screenshot builder for capturing page screenshots.
541 ///
542 /// # Example
543 ///
544 /// ```no_run
545 /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
546 /// // Capture viewport screenshot
547 /// let bytes = page.screenshot().capture().await?;
548 ///
549 /// // Capture full page screenshot
550 /// page.screenshot()
551 /// .full_page(true)
552 /// .path("screenshot.png")
553 /// .capture()
554 /// .await?;
555 ///
556 /// // Capture JPEG with quality
557 /// page.screenshot()
558 /// .jpeg(Some(80))
559 /// .path("screenshot.jpg")
560 /// .capture()
561 /// .await?;
562 /// # Ok(())
563 /// # }
564 /// ```
565 pub fn screenshot(&self) -> screenshot::ScreenshotBuilder<'_> {
566 screenshot::ScreenshotBuilder::new(self)
567 }
568
569 /// Create a PDF builder for generating PDFs from the page.
570 ///
571 /// # Example
572 ///
573 /// ```no_run
574 /// use viewpoint_core::page::PaperFormat;
575 ///
576 /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
577 /// // Generate PDF with default settings
578 /// let bytes = page.pdf().generate().await?;
579 ///
580 /// // Generate A4 landscape PDF
581 /// page.pdf()
582 /// .format(PaperFormat::A4)
583 /// .landscape(true)
584 /// .path("document.pdf")
585 /// .generate()
586 /// .await?;
587 ///
588 /// // Generate PDF with custom margins
589 /// page.pdf()
590 /// .margin(1.0) // 1 inch margins
591 /// .print_background(true)
592 /// .generate()
593 /// .await?;
594 /// # Ok(())
595 /// # }
596 /// ```
597 pub fn pdf(&self) -> pdf::PdfBuilder<'_> {
598 pdf::PdfBuilder::new(self)
599 }
600
601 /// Get the current page URL.
602 ///
603 /// # Errors
604 ///
605 /// Returns an error if the page is closed or the evaluation fails.
606 pub async fn url(&self) -> Result<String, PageError> {
607 if self.closed {
608 return Err(PageError::Closed);
609 }
610
611 let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
612 .connection
613 .send_command(
614 "Runtime.evaluate",
615 Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
616 expression: js! { window.location.href }.to_string(),
617 object_group: None,
618 include_command_line_api: None,
619 silent: Some(true),
620 context_id: None,
621 return_by_value: Some(true),
622 await_promise: Some(false),
623 }),
624 Some(&self.session_id),
625 )
626 .await?;
627
628 result
629 .result
630 .value
631 .and_then(|v| v.as_str().map(std::string::ToString::to_string))
632 .ok_or_else(|| PageError::EvaluationFailed("Failed to get URL".to_string()))
633 }
634
635 /// Get the current page title.
636 ///
637 /// # Errors
638 ///
639 /// Returns an error if the page is closed or the evaluation fails.
640 pub async fn title(&self) -> Result<String, PageError> {
641 if self.closed {
642 return Err(PageError::Closed);
643 }
644
645 let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
646 .connection
647 .send_command(
648 "Runtime.evaluate",
649 Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
650 expression: "document.title".to_string(),
651 object_group: None,
652 include_command_line_api: None,
653 silent: Some(true),
654 context_id: None,
655 return_by_value: Some(true),
656 await_promise: Some(false),
657 }),
658 Some(&self.session_id),
659 )
660 .await?;
661
662 result
663 .result
664 .value
665 .and_then(|v| v.as_str().map(std::string::ToString::to_string))
666 .ok_or_else(|| PageError::EvaluationFailed("Failed to get title".to_string()))
667 }
668}
669
670// Additional Page methods are defined in:
671// - scripts.rs: add_init_script, add_init_script_path
672// - locator_handler.rs: add_locator_handler, add_locator_handler_with_options, remove_locator_handler
673// - video.rs: video, start_video_recording, stop_video_recording
674// - binding.rs: expose_function, remove_exposed_function
675// - input_devices.rs: keyboard, mouse, touchscreen, clock, drag_and_drop
676// - locator_factory.rs: locator, get_by_*, set_test_id_attribute
677// - DragAndDropBuilder is defined in mouse.rs
678// - RoleLocatorBuilder is defined in locator/mod.rs