viewpoint_core/page/locator/
mod.rs

1//! Locator system for element selection.
2//!
3//! Locators are lazy handles that store selection criteria but don't query the DOM
4//! until an action is performed. This enables auto-waiting and chainable refinement.
5//!
6//! # Example
7//!
8//! ```
9//! # #[cfg(feature = "integration")]
10//! # tokio_test::block_on(async {
11//! # use viewpoint_core::Browser;
12//! use viewpoint_core::AriaRole;
13//! # let browser = Browser::launch().headless(true).launch().await.unwrap();
14//! # let context = browser.new_context().await.unwrap();
15//! # let page = context.new_page().await.unwrap();
16//! # page.goto("about:blank").goto().await.unwrap();
17//!
18//! // CSS selector
19//! let button = page.locator("button.submit");
20//!
21//! // Text locator
22//! let heading = page.get_by_text("Welcome");
23//!
24//! // Role locator
25//! let submit = page.get_by_role(AriaRole::Button).with_name("Submit");
26//!
27//! // Chained locators
28//! let item = page.locator(".list").locator(".item").first();
29//! # });
30//! ```
31
32mod actions;
33pub mod aria;
34mod aria_js;
35mod aria_role;
36mod builders;
37mod debug;
38mod element;
39mod evaluation;
40mod files;
41mod filter;
42mod helpers;
43mod queries;
44mod select;
45pub(crate) mod selector;
46
47use std::time::Duration;
48
49pub use aria::{AriaCheckedState, AriaSnapshot};
50pub use builders::{ClickBuilder, HoverBuilder, TapBuilder, TypeBuilder};
51pub use element::{BoundingBox, BoxModel, ElementHandle};
52pub use filter::{FilterBuilder, RoleLocatorBuilder};
53pub use selector::{AriaRole, Selector, TextOptions};
54
55use crate::Page;
56
57/// Default timeout for locator operations.
58const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
59
60/// A locator for finding elements on a page.
61///
62/// Locators are lightweight handles that store selection criteria. They don't
63/// query the DOM until an action is performed, enabling auto-waiting.
64#[derive(Debug, Clone)]
65pub struct Locator<'a> {
66    /// Reference to the page.
67    page: &'a Page,
68    /// The selector for finding elements.
69    selector: Selector,
70    /// Locator options.
71    options: LocatorOptions,
72}
73
74/// Options for locator behavior.
75#[derive(Debug, Clone)]
76pub struct LocatorOptions {
77    /// Timeout for operations.
78    pub timeout: Duration,
79}
80
81impl Default for LocatorOptions {
82    fn default() -> Self {
83        Self {
84            timeout: DEFAULT_TIMEOUT,
85        }
86    }
87}
88
89impl<'a> Locator<'a> {
90    /// Create a new locator.
91    pub(crate) fn new(page: &'a Page, selector: Selector) -> Self {
92        Self {
93            page,
94            selector,
95            options: LocatorOptions::default(),
96        }
97    }
98
99    /// Create a new locator with custom options.
100    pub(crate) fn with_options(
101        page: &'a Page,
102        selector: Selector,
103        options: LocatorOptions,
104    ) -> Self {
105        Self {
106            page,
107            selector,
108            options,
109        }
110    }
111
112    /// Get the page this locator belongs to.
113    pub fn page(&self) -> &'a Page {
114        self.page
115    }
116
117    /// Get the selector.
118    pub fn selector(&self) -> &Selector {
119        &self.selector
120    }
121
122    /// Get the options.
123    pub fn options(&self) -> &LocatorOptions {
124        &self.options
125    }
126
127    /// Set a custom timeout for this locator.
128    #[must_use]
129    pub fn timeout(mut self, timeout: Duration) -> Self {
130        self.options.timeout = timeout;
131        self
132    }
133
134    /// Create a child locator that further filters elements.
135    ///
136    /// # Example
137    ///
138    /// ```no_run
139    /// use viewpoint_core::Page;
140    ///
141    /// # fn example(page: &Page) {
142    /// let items = page.locator(".list").locator(".item");
143    /// # }
144    /// ```
145    #[must_use]
146    pub fn locator(&self, selector: impl Into<String>) -> Locator<'a> {
147        Locator {
148            page: self.page,
149            selector: Selector::Chained(
150                Box::new(self.selector.clone()),
151                Box::new(Selector::Css(selector.into())),
152            ),
153            options: self.options.clone(),
154        }
155    }
156
157    /// Select the first matching element.
158    #[must_use]
159    pub fn first(&self) -> Locator<'a> {
160        Locator {
161            page: self.page,
162            selector: Selector::Nth {
163                base: Box::new(self.selector.clone()),
164                index: 0,
165            },
166            options: self.options.clone(),
167        }
168    }
169
170    /// Select the last matching element.
171    #[must_use]
172    pub fn last(&self) -> Locator<'a> {
173        Locator {
174            page: self.page,
175            selector: Selector::Nth {
176                base: Box::new(self.selector.clone()),
177                index: -1,
178            },
179            options: self.options.clone(),
180        }
181    }
182
183    /// Select the nth matching element (0-indexed).
184    #[must_use]
185    pub fn nth(&self, index: i32) -> Locator<'a> {
186        Locator {
187            page: self.page,
188            selector: Selector::Nth {
189                base: Box::new(self.selector.clone()),
190                index,
191            },
192            options: self.options.clone(),
193        }
194    }
195
196    /// Convert the selector to a JavaScript expression for CDP evaluation.
197    pub(crate) fn to_js_selector(&self) -> String {
198        self.selector.to_js_expression()
199    }
200
201    /// Create a locator that matches elements that match both this locator and `other`.
202    ///
203    /// # Example
204    ///
205    /// ```no_run
206    /// use viewpoint_core::{Page, AriaRole};
207    ///
208    /// # fn example(page: &Page) {
209    /// // Find visible buttons with specific text
210    /// let button = page.get_by_role(AriaRole::Button).build()
211    ///     .and(page.get_by_text("Submit"));
212    /// # }
213    /// ```
214    #[must_use]
215    pub fn and(&self, other: Locator<'a>) -> Locator<'a> {
216        Locator {
217            page: self.page,
218            selector: Selector::And(Box::new(self.selector.clone()), Box::new(other.selector)),
219            options: self.options.clone(),
220        }
221    }
222
223    /// Create a locator that matches elements that match either this locator or `other`.
224    ///
225    /// # Example
226    ///
227    /// ```no_run
228    /// use viewpoint_core::{Page, AriaRole};
229    ///
230    /// # fn example(page: &Page) {
231    /// // Find buttons or links
232    /// let clickable = page.get_by_role(AriaRole::Button).build()
233    ///     .or(page.get_by_role(AriaRole::Link).build());
234    /// # }
235    /// ```
236    #[must_use]
237    pub fn or(&self, other: Locator<'a>) -> Locator<'a> {
238        Locator {
239            page: self.page,
240            selector: Selector::Or(Box::new(self.selector.clone()), Box::new(other.selector)),
241            options: self.options.clone(),
242        }
243    }
244
245    /// Create a filter builder to narrow down the elements matched by this locator.
246    ///
247    /// # Example
248    ///
249    /// ```no_run
250    /// use viewpoint_core::Page;
251    ///
252    /// # fn example(page: &Page) {
253    /// // Filter list items by text
254    /// let item = page.locator("li").filter().has_text("Product");
255    ///
256    /// // Filter by having a child element
257    /// let rows = page.locator("tr").filter().has(page.locator(".active"));
258    /// # }
259    /// ```
260    pub fn filter(&self) -> FilterBuilder<'a> {
261        FilterBuilder::new(self.page, self.selector.clone(), self.options.clone())
262    }
263
264    /// Get an ARIA accessibility snapshot of this element.
265    ///
266    /// The snapshot captures the accessible tree structure as it would be
267    /// exposed to assistive technologies. This is useful for accessibility testing.
268    ///
269    /// # Example
270    ///
271    /// ```no_run
272    /// use viewpoint_core::Page;
273    ///
274    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
275    /// let snapshot = page.locator("form").aria_snapshot().await?;
276    /// println!("{}", snapshot); // YAML-like output
277    /// # Ok(())
278    /// # }
279    /// ```
280    ///
281    /// # Errors
282    ///
283    /// Returns an error if the element is not found or snapshot capture fails.
284    pub async fn aria_snapshot(&self) -> Result<AriaSnapshot, crate::error::LocatorError> {
285        use crate::error::LocatorError;
286
287        if self.page.is_closed() {
288            return Err(LocatorError::PageClosed);
289        }
290
291        // Get the element and evaluate ARIA snapshot
292        let js_selector = self.selector.to_js_expression();
293        let js = format!(
294            r"
295            (function() {{
296                const element = {};
297                if (!element) {{
298                    return {{ error: 'Element not found' }};
299                }}
300                const getSnapshot = {};
301                return getSnapshot(element);
302            }})()
303            ",
304            js_selector,
305            aria::aria_snapshot_js()
306        );
307
308        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
309            .page
310            .connection()
311            .send_command(
312                "Runtime.evaluate",
313                Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
314                    expression: js,
315                    object_group: None,
316                    include_command_line_api: None,
317                    silent: Some(true),
318                    context_id: None,
319                    return_by_value: Some(true),
320                    await_promise: Some(false),
321                }),
322                Some(self.page.session_id()),
323            )
324            .await?;
325
326        if let Some(exception) = result.exception_details {
327            return Err(LocatorError::EvaluationError(exception.text));
328        }
329
330        let value = result.result.value.ok_or_else(|| {
331            LocatorError::EvaluationError("No result from aria snapshot".to_string())
332        })?;
333
334        // Check for error
335        if let Some(error) = value.get("error").and_then(|e| e.as_str()) {
336            return Err(LocatorError::NotFound(error.to_string()));
337        }
338
339        // Parse the snapshot
340        let snapshot: AriaSnapshot = serde_json::from_value(value).map_err(|e| {
341            LocatorError::EvaluationError(format!("Failed to parse aria snapshot: {e}"))
342        })?;
343
344        Ok(snapshot)
345    }
346}
347
348// FilterBuilder and RoleLocatorBuilder are in filter.rs