viewpoint_core/page/
mod.rs

1//! Page management and navigation.
2
3pub mod locator;
4mod navigation;
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use viewpoint_cdp::protocol::page::{NavigateParams, NavigateResult};
10use viewpoint_cdp::protocol::target::CloseTargetParams;
11use viewpoint_cdp::CdpConnection;
12use tracing::{debug, info, instrument, trace, warn};
13
14use crate::error::{NavigationError, PageError};
15use crate::wait::{DocumentLoadState, LoadStateWaiter};
16
17pub use locator::{AriaRole, Locator, LocatorOptions, Selector, TextOptions};
18pub use navigation::GotoBuilder;
19
20/// Default navigation timeout.
21const DEFAULT_NAVIGATION_TIMEOUT: Duration = Duration::from_secs(30);
22
23/// A browser page (tab).
24#[derive(Debug)]
25pub struct Page {
26    /// CDP connection.
27    connection: Arc<CdpConnection>,
28    /// Target ID.
29    target_id: String,
30    /// Session ID for this page.
31    session_id: String,
32    /// Main frame ID.
33    frame_id: String,
34    /// Whether the page has been closed.
35    closed: bool,
36}
37
38impl Page {
39    /// Create a new page.
40    pub(crate) fn new(
41        connection: Arc<CdpConnection>,
42        target_id: String,
43        session_id: String,
44        frame_id: String,
45    ) -> Self {
46        Self {
47            connection,
48            target_id,
49            session_id,
50            frame_id,
51            closed: false,
52        }
53    }
54
55    /// Navigate to a URL.
56    ///
57    /// Returns a builder for configuring navigation options.
58    ///
59    /// # Example
60    ///
61    /// ```no_run
62    /// use viewpoint_core::Page;
63    /// use viewpoint_core::DocumentLoadState;
64    /// use std::time::Duration;
65    ///
66    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
67    /// // Simple navigation
68    /// page.goto("https://example.com").goto().await?;
69    ///
70    /// // Navigation with options
71    /// page.goto("https://example.com")
72    ///     .wait_until(DocumentLoadState::DomContentLoaded)
73    ///     .timeout(Duration::from_secs(10))
74    ///     .goto()
75    ///     .await?;
76    /// # Ok(())
77    /// # }
78    /// ```
79    pub fn goto(&self, url: impl Into<String>) -> GotoBuilder<'_> {
80        GotoBuilder::new(self, url.into())
81    }
82
83    /// Navigate to a URL and wait for the specified load state.
84    ///
85    /// This is a convenience method that calls `goto(url).goto().await`.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if:
90    /// - The page is closed
91    /// - Navigation fails
92    /// - The wait times out
93    pub async fn goto_url(&self, url: &str) -> Result<NavigationResponse, NavigationError> {
94        self.goto(url).goto().await
95    }
96
97    /// Navigate to a URL with the given options.
98    #[instrument(level = "info", skip(self), fields(target_id = %self.target_id, url = %url, wait_until = ?wait_until, timeout_ms = timeout.as_millis()))]
99    pub(crate) async fn navigate_internal(
100        &self,
101        url: &str,
102        wait_until: DocumentLoadState,
103        timeout: Duration,
104        referer: Option<&str>,
105    ) -> Result<NavigationResponse, NavigationError> {
106        if self.closed {
107            warn!("Attempted navigation on closed page");
108            return Err(NavigationError::Cancelled);
109        }
110
111        info!("Starting navigation");
112
113        // Create a load state waiter
114        let event_rx = self.connection.subscribe_events();
115        let mut waiter = LoadStateWaiter::new(
116            event_rx,
117            self.session_id.clone(),
118            self.frame_id.clone(),
119        );
120        trace!("Created load state waiter");
121
122        // Send the navigation command
123        debug!("Sending Page.navigate command");
124        let result: NavigateResult = self
125            .connection
126            .send_command(
127                "Page.navigate",
128                Some(NavigateParams {
129                    url: url.to_string(),
130                    referrer: referer.map(ToString::to_string),
131                    transition_type: None,
132                    frame_id: None,
133                }),
134                Some(&self.session_id),
135            )
136            .await?;
137
138        debug!(frame_id = %result.frame_id, loader_id = ?result.loader_id, "Page.navigate completed");
139
140        // Check for navigation errors
141        if let Some(error_text) = result.error_text {
142            warn!(error = %error_text, "Navigation failed with error");
143            return Err(NavigationError::NetworkError(error_text));
144        }
145
146        // Mark commit as received
147        trace!("Setting commit received");
148        waiter.set_commit_received().await;
149
150        // Wait for the target load state
151        debug!(wait_until = ?wait_until, "Waiting for load state");
152        waiter
153            .wait_for_load_state_with_timeout(wait_until, timeout)
154            .await?;
155
156        info!(frame_id = %result.frame_id, "Navigation completed successfully");
157
158        Ok(NavigationResponse {
159            url: url.to_string(),
160            frame_id: result.frame_id,
161        })
162    }
163
164    /// Close this page.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if closing fails.
169    #[instrument(level = "info", skip(self), fields(target_id = %self.target_id))]
170    pub async fn close(&mut self) -> Result<(), PageError> {
171        if self.closed {
172            debug!("Page already closed");
173            return Ok(());
174        }
175
176        info!("Closing page");
177
178        self.connection
179            .send_command::<_, serde_json::Value>(
180                "Target.closeTarget",
181                Some(CloseTargetParams {
182                    target_id: self.target_id.clone(),
183                }),
184                None,
185            )
186            .await?;
187
188        self.closed = true;
189        info!("Page closed");
190        Ok(())
191    }
192
193    /// Get the target ID.
194    pub fn target_id(&self) -> &str {
195        &self.target_id
196    }
197
198    /// Get the session ID.
199    pub fn session_id(&self) -> &str {
200        &self.session_id
201    }
202
203    /// Get the main frame ID.
204    pub fn frame_id(&self) -> &str {
205        &self.frame_id
206    }
207
208    /// Check if this page has been closed.
209    pub fn is_closed(&self) -> bool {
210        self.closed
211    }
212
213    /// Get a reference to the CDP connection.
214    pub fn connection(&self) -> &Arc<CdpConnection> {
215        &self.connection
216    }
217
218    /// Get the current page URL.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the page is closed or the evaluation fails.
223    pub async fn url(&self) -> Result<String, PageError> {
224        if self.closed {
225            return Err(PageError::Closed);
226        }
227
228        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
229            .connection
230            .send_command(
231                "Runtime.evaluate",
232                Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
233                    expression: "window.location.href".to_string(),
234                    object_group: None,
235                    include_command_line_api: None,
236                    silent: Some(true),
237                    context_id: None,
238                    return_by_value: Some(true),
239                    await_promise: Some(false),
240                }),
241                Some(&self.session_id),
242            )
243            .await?;
244
245        result
246            .result
247            .value
248            .and_then(|v| v.as_str().map(std::string::ToString::to_string))
249            .ok_or_else(|| PageError::EvaluationFailed("Failed to get URL".to_string()))
250    }
251
252    /// Get the current page title.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if the page is closed or the evaluation fails.
257    pub async fn title(&self) -> Result<String, PageError> {
258        if self.closed {
259            return Err(PageError::Closed);
260        }
261
262        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
263            .connection
264            .send_command(
265                "Runtime.evaluate",
266                Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
267                    expression: "document.title".to_string(),
268                    object_group: None,
269                    include_command_line_api: None,
270                    silent: Some(true),
271                    context_id: None,
272                    return_by_value: Some(true),
273                    await_promise: Some(false),
274                }),
275                Some(&self.session_id),
276            )
277            .await?;
278
279        result
280            .result
281            .value
282            .and_then(|v| v.as_str().map(std::string::ToString::to_string))
283            .ok_or_else(|| PageError::EvaluationFailed("Failed to get title".to_string()))
284    }
285
286    // =========================================================================
287    // Locator Methods
288    // =========================================================================
289
290    /// Create a locator for elements matching a CSS selector.
291    ///
292    /// # Example
293    ///
294    /// ```ignore
295    /// let button = page.locator("button.submit");
296    /// let items = page.locator(".list > .item");
297    /// ```
298    pub fn locator(&self, selector: impl Into<String>) -> Locator<'_> {
299        Locator::new(self, Selector::Css(selector.into()))
300    }
301
302    /// Create a locator for elements containing the specified text.
303    ///
304    /// # Example
305    ///
306    /// ```ignore
307    /// let heading = page.get_by_text("Welcome");
308    /// let exact = page.get_by_text_exact("Welcome to our site");
309    /// ```
310    pub fn get_by_text(&self, text: impl Into<String>) -> Locator<'_> {
311        Locator::new(
312            self,
313            Selector::Text {
314                text: text.into(),
315                exact: false,
316            },
317        )
318    }
319
320    /// Create a locator for elements with exact text content.
321    pub fn get_by_text_exact(&self, text: impl Into<String>) -> Locator<'_> {
322        Locator::new(
323            self,
324            Selector::Text {
325                text: text.into(),
326                exact: true,
327            },
328        )
329    }
330
331    /// Create a locator for elements with the specified ARIA role.
332    ///
333    /// # Example
334    ///
335    /// ```ignore
336    /// let buttons = page.get_by_role(AriaRole::Button);
337    /// let submit = page.get_by_role(AriaRole::Button).with_name("Submit");
338    /// ```
339    pub fn get_by_role(&self, role: AriaRole) -> RoleLocatorBuilder<'_> {
340        RoleLocatorBuilder::new(self, role)
341    }
342
343    /// Create a locator for elements with the specified test ID.
344    ///
345    /// By default, looks for `data-testid` attribute.
346    ///
347    /// # Example
348    ///
349    /// ```ignore
350    /// let button = page.get_by_test_id("submit-button");
351    /// ```
352    pub fn get_by_test_id(&self, test_id: impl Into<String>) -> Locator<'_> {
353        Locator::new(self, Selector::TestId(test_id.into()))
354    }
355
356    /// Create a locator for form controls by their associated label text.
357    ///
358    /// # Example
359    ///
360    /// ```ignore
361    /// let email = page.get_by_label("Email address");
362    /// ```
363    pub fn get_by_label(&self, label: impl Into<String>) -> Locator<'_> {
364        Locator::new(self, Selector::Label(label.into()))
365    }
366
367    /// Create a locator for inputs by their placeholder text.
368    ///
369    /// # Example
370    ///
371    /// ```ignore
372    /// let search = page.get_by_placeholder("Search...");
373    /// ```
374    pub fn get_by_placeholder(&self, placeholder: impl Into<String>) -> Locator<'_> {
375        Locator::new(self, Selector::Placeholder(placeholder.into()))
376    }
377}
378
379/// Builder for role-based locators.
380#[derive(Debug)]
381pub struct RoleLocatorBuilder<'a> {
382    page: &'a Page,
383    role: AriaRole,
384    name: Option<String>,
385}
386
387impl<'a> RoleLocatorBuilder<'a> {
388    fn new(page: &'a Page, role: AriaRole) -> Self {
389        Self {
390            page,
391            role,
392            name: None,
393        }
394    }
395
396    /// Filter by accessible name.
397    #[must_use]
398    pub fn with_name(mut self, name: impl Into<String>) -> Self {
399        self.name = Some(name.into());
400        self
401    }
402
403    /// Build the locator.
404    pub fn build(self) -> Locator<'a> {
405        Locator::new(
406            self.page,
407            Selector::Role {
408                role: self.role,
409                name: self.name,
410            },
411        )
412    }
413}
414
415impl<'a> From<RoleLocatorBuilder<'a>> for Locator<'a> {
416    fn from(builder: RoleLocatorBuilder<'a>) -> Self {
417        builder.build()
418    }
419}
420
421/// Response from a navigation.
422#[derive(Debug, Clone)]
423pub struct NavigationResponse {
424    /// The URL that was navigated to.
425    pub url: String,
426    /// The frame ID that navigated.
427    pub frame_id: String,
428}