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