viewpoint_core/page/
mod.rs

1//! Page management and navigation.
2
3mod aria_snapshot;
4pub mod binding;
5pub mod clock;
6mod clock_script;
7pub mod console;
8mod constructors;
9mod content;
10pub mod dialog;
11pub mod download;
12pub mod emulation;
13mod evaluate;
14pub mod events;
15pub mod file_chooser;
16pub mod frame;
17pub mod frame_locator;
18mod frame_locator_actions;
19mod frame_page_methods;
20mod input_devices;
21pub mod keyboard;
22pub mod locator;
23mod locator_factory;
24pub mod locator_handler;
25mod mouse;
26mod mouse_drag;
27mod navigation;
28pub mod page_error;
29mod pdf;
30pub mod popup;
31mod routing_impl;
32mod screenshot;
33mod screenshot_element;
34mod scripts;
35mod touchscreen;
36pub mod video;
37mod video_io;
38
39use std::sync::Arc;
40use std::time::Duration;
41
42use tracing::{debug, info, instrument, trace, warn};
43use viewpoint_cdp::CdpConnection;
44use viewpoint_cdp::protocol::page::{NavigateParams, NavigateResult};
45use viewpoint_cdp::protocol::target_domain::CloseTargetParams;
46use viewpoint_js::js;
47
48use crate::error::{NavigationError, PageError};
49use crate::network::{RouteHandlerRegistry, WebSocketManager};
50use crate::wait::{DocumentLoadState, LoadStateWaiter};
51
52pub use clock::{Clock, TimeValue};
53pub use console::{ConsoleMessage, ConsoleMessageLocation, ConsoleMessageType, JsArg};
54pub use content::{ScriptTagBuilder, ScriptType, SetContentBuilder, StyleTagBuilder};
55pub use dialog::Dialog;
56pub use download::{Download, DownloadState};
57pub use emulation::{EmulateMediaBuilder, MediaType, VisionDeficiency};
58pub use evaluate::{JsHandle, Polling, WaitForFunctionBuilder};
59pub use events::PageEventManager;
60pub use file_chooser::{FileChooser, FilePayload};
61pub use frame::Frame;
62pub use frame_locator::{FrameElementLocator, FrameLocator, FrameRoleLocatorBuilder};
63pub use keyboard::Keyboard;
64pub use locator::{
65    AriaCheckedState, AriaRole, AriaSnapshot, BoundingBox, BoxModel, ElementHandle, FilterBuilder,
66    Locator, LocatorOptions, RoleLocatorBuilder, Selector, TapBuilder, TextOptions,
67};
68pub use locator_handler::{LocatorHandlerHandle, LocatorHandlerManager, LocatorHandlerOptions};
69pub use mouse::Mouse;
70pub use mouse_drag::DragAndDropBuilder;
71pub use navigation::{GotoBuilder, NavigationResponse};
72pub use page_error::{PageError as PageErrorInfo, WebError};
73pub use pdf::{Margins, PaperFormat, PdfBuilder};
74pub use screenshot::{Animations, ClipRegion, ScreenshotBuilder, ScreenshotFormat};
75pub use touchscreen::Touchscreen;
76pub use video::{Video, VideoOptions};
77pub use viewpoint_cdp::protocol::DialogType;
78pub use viewpoint_cdp::protocol::emulation::ViewportSize;
79pub use viewpoint_cdp::protocol::input::MouseButton;
80
81/// Default navigation timeout.
82const DEFAULT_NAVIGATION_TIMEOUT: Duration = Duration::from_secs(30);
83
84/// Default test ID attribute name.
85pub const DEFAULT_TEST_ID_ATTRIBUTE: &str = "data-testid";
86
87/// A browser page (tab).
88pub struct Page {
89    /// CDP connection.
90    connection: Arc<CdpConnection>,
91    /// Target ID.
92    target_id: String,
93    /// Session ID for this page.
94    session_id: String,
95    /// Main frame ID.
96    frame_id: String,
97    /// Whether the page has been closed.
98    closed: bool,
99    /// Route handler registry.
100    route_registry: Arc<RouteHandlerRegistry>,
101    /// Keyboard controller.
102    keyboard: Keyboard,
103    /// Mouse controller.
104    mouse: Mouse,
105    /// Touchscreen controller.
106    touchscreen: Touchscreen,
107    /// Event manager for dialogs, downloads, and file choosers.
108    event_manager: Arc<PageEventManager>,
109    /// Locator handler manager.
110    locator_handler_manager: Arc<LocatorHandlerManager>,
111    /// Video recording controller (if recording is enabled).
112    video_controller: Option<Arc<Video>>,
113    /// Opener target ID (for popup pages).
114    opener_target_id: Option<String>,
115    /// Popup event manager.
116    popup_manager: Arc<popup::PopupManager>,
117    /// WebSocket event manager.
118    websocket_manager: Arc<WebSocketManager>,
119    /// Exposed function binding manager.
120    binding_manager: Arc<binding::BindingManager>,
121    /// Custom test ID attribute (defaults to "data-testid").
122    test_id_attribute: String,
123}
124
125// Manual Debug implementation since some fields don't implement Debug
126impl std::fmt::Debug for Page {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("Page")
129            .field("target_id", &self.target_id)
130            .field("session_id", &self.session_id)
131            .field("frame_id", &self.frame_id)
132            .field("closed", &self.closed)
133            .finish_non_exhaustive()
134    }
135}
136
137impl Page {
138    /// Navigate to a URL.
139    ///
140    /// Returns a builder for configuring navigation options.
141    ///
142    /// # Example
143    ///
144    /// ```no_run
145    /// use viewpoint_core::Page;
146    /// use viewpoint_core::DocumentLoadState;
147    /// use std::time::Duration;
148    ///
149    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
150    /// // Simple navigation
151    /// page.goto("https://example.com").goto().await?;
152    ///
153    /// // Navigation with options
154    /// page.goto("https://example.com")
155    ///     .wait_until(DocumentLoadState::DomContentLoaded)
156    ///     .timeout(Duration::from_secs(10))
157    ///     .goto()
158    ///     .await?;
159    /// # Ok(())
160    /// # }
161    /// ```
162    pub fn goto(&self, url: impl Into<String>) -> GotoBuilder<'_> {
163        GotoBuilder::new(self, url.into())
164    }
165
166    /// Navigate to a URL and wait for the specified load state.
167    ///
168    /// This is a convenience method that calls `goto(url).goto().await`.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if:
173    /// - The page is closed
174    /// - Navigation fails
175    /// - The wait times out
176    pub async fn goto_url(&self, url: &str) -> Result<NavigationResponse, NavigationError> {
177        self.goto(url).goto().await
178    }
179
180    /// Navigate to a URL with the given options.
181    #[instrument(level = "info", skip(self), fields(target_id = %self.target_id, url = %url, wait_until = ?wait_until, timeout_ms = timeout.as_millis()))]
182    pub(crate) async fn navigate_internal(
183        &self,
184        url: &str,
185        wait_until: DocumentLoadState,
186        timeout: Duration,
187        referer: Option<&str>,
188    ) -> Result<NavigationResponse, NavigationError> {
189        if self.closed {
190            warn!("Attempted navigation on closed page");
191            return Err(NavigationError::Cancelled);
192        }
193
194        info!("Starting navigation");
195
196        // Create a load state waiter
197        let event_rx = self.connection.subscribe_events();
198        let mut waiter =
199            LoadStateWaiter::new(event_rx, self.session_id.clone(), self.frame_id.clone());
200        trace!("Created load state waiter");
201
202        // Send the navigation command
203        debug!("Sending Page.navigate command");
204        let result: NavigateResult = self
205            .connection
206            .send_command(
207                "Page.navigate",
208                Some(NavigateParams {
209                    url: url.to_string(),
210                    referrer: referer.map(ToString::to_string),
211                    transition_type: None,
212                    frame_id: None,
213                }),
214                Some(&self.session_id),
215            )
216            .await?;
217
218        debug!(frame_id = %result.frame_id, loader_id = ?result.loader_id, "Page.navigate completed");
219
220        // Check for navigation errors
221        // Note: Chrome reports HTTP error status codes (4xx, 5xx) as errors with
222        // "net::ERR_HTTP_RESPONSE_CODE_FAILURE" or "net::ERR_INVALID_AUTH_CREDENTIALS".
223        // Following Playwright's behavior, we treat these as successful navigations
224        // that return a response with the appropriate status code.
225        if let Some(ref error_text) = result.error_text {
226            let is_http_error = error_text == "net::ERR_HTTP_RESPONSE_CODE_FAILURE"
227                || error_text == "net::ERR_INVALID_AUTH_CREDENTIALS";
228
229            if !is_http_error {
230                warn!(error = %error_text, "Navigation failed with error");
231                return Err(NavigationError::NetworkError(error_text.clone()));
232            }
233            debug!(error = %error_text, "HTTP error response - continuing to capture status");
234        }
235
236        // Mark commit as received
237        trace!("Setting commit received");
238        waiter.set_commit_received().await;
239
240        // Wait for the target load state
241        debug!(wait_until = ?wait_until, "Waiting for load state");
242        waiter
243            .wait_for_load_state_with_timeout(wait_until, timeout)
244            .await?;
245
246        // Get response data captured during navigation
247        let response_data = waiter.response_data().await;
248
249        info!(frame_id = %result.frame_id, "Navigation completed successfully");
250
251        // Use the final URL from response data if available (handles redirects)
252        let final_url = response_data.url.unwrap_or_else(|| url.to_string());
253
254        // Build the response with captured data
255        if let Some(status) = response_data.status {
256            Ok(NavigationResponse::with_response(
257                final_url,
258                result.frame_id,
259                status,
260                response_data.headers,
261            ))
262        } else {
263            Ok(NavigationResponse::new(final_url, result.frame_id))
264        }
265    }
266
267    /// Close this page.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if closing fails.
272    #[instrument(level = "info", skip(self), fields(target_id = %self.target_id))]
273    pub async fn close(&mut self) -> Result<(), PageError> {
274        if self.closed {
275            debug!("Page already closed");
276            return Ok(());
277        }
278
279        info!("Closing page");
280
281        // Clean up route handlers
282        self.route_registry.unroute_all().await;
283        debug!("Route handlers cleaned up");
284
285        self.connection
286            .send_command::<_, serde_json::Value>(
287                "Target.closeTarget",
288                Some(CloseTargetParams {
289                    target_id: self.target_id.clone(),
290                }),
291                None,
292            )
293            .await?;
294
295        self.closed = true;
296        info!("Page closed");
297        Ok(())
298    }
299
300    /// Get the target ID.
301    pub fn target_id(&self) -> &str {
302        &self.target_id
303    }
304
305    /// Get the session ID.
306    pub fn session_id(&self) -> &str {
307        &self.session_id
308    }
309
310    /// Get the main frame ID.
311    pub fn frame_id(&self) -> &str {
312        &self.frame_id
313    }
314
315    /// Check if this page has been closed.
316    pub fn is_closed(&self) -> bool {
317        self.closed
318    }
319
320    /// Get a reference to the CDP connection.
321    pub fn connection(&self) -> &Arc<CdpConnection> {
322        &self.connection
323    }
324
325    // =========================================================================
326    // Screenshot & PDF Methods
327    // =========================================================================
328
329    /// Create a screenshot builder for capturing page screenshots.
330    ///
331    /// # Example
332    ///
333    /// ```no_run
334    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
335    /// // Capture viewport screenshot
336    /// let bytes = page.screenshot().capture().await?;
337    ///
338    /// // Capture full page screenshot
339    /// page.screenshot()
340    ///     .full_page(true)
341    ///     .path("screenshot.png")
342    ///     .capture()
343    ///     .await?;
344    ///
345    /// // Capture JPEG with quality
346    /// page.screenshot()
347    ///     .jpeg(Some(80))
348    ///     .path("screenshot.jpg")
349    ///     .capture()
350    ///     .await?;
351    /// # Ok(())
352    /// # }
353    /// ```
354    pub fn screenshot(&self) -> screenshot::ScreenshotBuilder<'_> {
355        screenshot::ScreenshotBuilder::new(self)
356    }
357
358    /// Create a PDF builder for generating PDFs from the page.
359    ///
360    /// # Example
361    ///
362    /// ```no_run
363    /// use viewpoint_core::page::PaperFormat;
364    ///
365    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
366    /// // Generate PDF with default settings
367    /// let bytes = page.pdf().generate().await?;
368    ///
369    /// // Generate A4 landscape PDF
370    /// page.pdf()
371    ///     .format(PaperFormat::A4)
372    ///     .landscape(true)
373    ///     .path("document.pdf")
374    ///     .generate()
375    ///     .await?;
376    ///
377    /// // Generate PDF with custom margins
378    /// page.pdf()
379    ///     .margin(1.0) // 1 inch margins
380    ///     .print_background(true)
381    ///     .generate()
382    ///     .await?;
383    /// # Ok(())
384    /// # }
385    /// ```
386    pub fn pdf(&self) -> pdf::PdfBuilder<'_> {
387        pdf::PdfBuilder::new(self)
388    }
389
390    /// Get the current page URL.
391    ///
392    /// # Errors
393    ///
394    /// Returns an error if the page is closed or the evaluation fails.
395    pub async fn url(&self) -> Result<String, PageError> {
396        if self.closed {
397            return Err(PageError::Closed);
398        }
399
400        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
401            .connection
402            .send_command(
403                "Runtime.evaluate",
404                Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
405                    expression: js! { window.location.href }.to_string(),
406                    object_group: None,
407                    include_command_line_api: None,
408                    silent: Some(true),
409                    context_id: None,
410                    return_by_value: Some(true),
411                    await_promise: Some(false),
412                }),
413                Some(&self.session_id),
414            )
415            .await?;
416
417        result
418            .result
419            .value
420            .and_then(|v| v.as_str().map(std::string::ToString::to_string))
421            .ok_or_else(|| PageError::EvaluationFailed("Failed to get URL".to_string()))
422    }
423
424    /// Get the current page title.
425    ///
426    /// # Errors
427    ///
428    /// Returns an error if the page is closed or the evaluation fails.
429    pub async fn title(&self) -> Result<String, PageError> {
430        if self.closed {
431            return Err(PageError::Closed);
432        }
433
434        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
435            .connection
436            .send_command(
437                "Runtime.evaluate",
438                Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
439                    expression: "document.title".to_string(),
440                    object_group: None,
441                    include_command_line_api: None,
442                    silent: Some(true),
443                    context_id: None,
444                    return_by_value: Some(true),
445                    await_promise: Some(false),
446                }),
447                Some(&self.session_id),
448            )
449            .await?;
450
451        result
452            .result
453            .value
454            .and_then(|v| v.as_str().map(std::string::ToString::to_string))
455            .ok_or_else(|| PageError::EvaluationFailed("Failed to get title".to_string()))
456    }
457}
458
459// Additional Page methods are defined in:
460// - scripts.rs: add_init_script, add_init_script_path
461// - locator_handler.rs: add_locator_handler, add_locator_handler_with_options, remove_locator_handler
462// - video.rs: video, start_video_recording, stop_video_recording
463// - binding.rs: expose_function, remove_exposed_function
464// - input_devices.rs: keyboard, mouse, touchscreen, clock, drag_and_drop
465// - locator_factory.rs: locator, get_by_*, set_test_id_attribute
466// - DragAndDropBuilder is defined in mouse.rs
467// - RoleLocatorBuilder is defined in locator/mod.rs