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