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