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;
33mod aria_role;
34mod builders;
35mod debug;
36mod element;
37mod evaluation;
38mod files;
39mod filter;
40mod helpers;
41mod queries;
42mod select;
43pub mod aria;
44mod aria_js;
45pub(crate) mod selector;
46
47use std::time::Duration;
48
49pub use builders::{ClickBuilder, HoverBuilder, TapBuilder, TypeBuilder};
50pub use element::{BoundingBox, BoxModel, ElementHandle};
51pub use filter::{FilterBuilder, RoleLocatorBuilder};
52pub use aria::{AriaCheckedState, AriaSnapshot};
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(page: &'a Page, selector: Selector, options: LocatorOptions) -> Self {
101        Self {
102            page,
103            selector,
104            options,
105        }
106    }
107
108    /// Get the page this locator belongs to.
109    pub fn page(&self) -> &'a Page {
110        self.page
111    }
112
113    /// Get the selector.
114    pub fn selector(&self) -> &Selector {
115        &self.selector
116    }
117
118    /// Get the options.
119    pub fn options(&self) -> &LocatorOptions {
120        &self.options
121    }
122
123    /// Set a custom timeout for this locator.
124    #[must_use]
125    pub fn timeout(mut self, timeout: Duration) -> Self {
126        self.options.timeout = timeout;
127        self
128    }
129
130    /// Create a child locator that further filters elements.
131    ///
132    /// # Example
133    ///
134    /// ```no_run
135    /// use viewpoint_core::Page;
136    ///
137    /// # fn example(page: &Page) {
138    /// let items = page.locator(".list").locator(".item");
139    /// # }
140    /// ```
141    #[must_use]
142    pub fn locator(&self, selector: impl Into<String>) -> Locator<'a> {
143        Locator {
144            page: self.page,
145            selector: Selector::Chained(
146                Box::new(self.selector.clone()),
147                Box::new(Selector::Css(selector.into())),
148            ),
149            options: self.options.clone(),
150        }
151    }
152
153    /// Select the first matching element.
154    #[must_use]
155    pub fn first(&self) -> Locator<'a> {
156        Locator {
157            page: self.page,
158            selector: Selector::Nth {
159                base: Box::new(self.selector.clone()),
160                index: 0,
161            },
162            options: self.options.clone(),
163        }
164    }
165
166    /// Select the last matching element.
167    #[must_use]
168    pub fn last(&self) -> Locator<'a> {
169        Locator {
170            page: self.page,
171            selector: Selector::Nth {
172                base: Box::new(self.selector.clone()),
173                index: -1,
174            },
175            options: self.options.clone(),
176        }
177    }
178
179    /// Select the nth matching element (0-indexed).
180    #[must_use]
181    pub fn nth(&self, index: i32) -> Locator<'a> {
182        Locator {
183            page: self.page,
184            selector: Selector::Nth {
185                base: Box::new(self.selector.clone()),
186                index,
187            },
188            options: self.options.clone(),
189        }
190    }
191
192    /// Convert the selector to a JavaScript expression for CDP evaluation.
193    pub(crate) fn to_js_selector(&self) -> String {
194        self.selector.to_js_expression()
195    }
196
197    /// Create a locator that matches elements that match both this locator and `other`.
198    ///
199    /// # Example
200    ///
201    /// ```no_run
202    /// use viewpoint_core::{Page, AriaRole};
203    ///
204    /// # fn example(page: &Page) {
205    /// // Find visible buttons with specific text
206    /// let button = page.get_by_role(AriaRole::Button).build()
207    ///     .and(page.get_by_text("Submit"));
208    /// # }
209    /// ```
210    #[must_use]
211    pub fn and(&self, other: Locator<'a>) -> Locator<'a> {
212        Locator {
213            page: self.page,
214            selector: Selector::And(
215                Box::new(self.selector.clone()),
216                Box::new(other.selector),
217            ),
218            options: self.options.clone(),
219        }
220    }
221
222    /// Create a locator that matches elements that match either this locator or `other`.
223    ///
224    /// # Example
225    ///
226    /// ```no_run
227    /// use viewpoint_core::{Page, AriaRole};
228    ///
229    /// # fn example(page: &Page) {
230    /// // Find buttons or links
231    /// let clickable = page.get_by_role(AriaRole::Button).build()
232    ///     .or(page.get_by_role(AriaRole::Link).build());
233    /// # }
234    /// ```
235    #[must_use]
236    pub fn or(&self, other: Locator<'a>) -> Locator<'a> {
237        Locator {
238            page: self.page,
239            selector: Selector::Or(
240                Box::new(self.selector.clone()),
241                Box::new(other.selector),
242            ),
243            options: self.options.clone(),
244        }
245    }
246
247    /// Create a filter builder to narrow down the elements matched by this locator.
248    ///
249    /// # Example
250    ///
251    /// ```no_run
252    /// use viewpoint_core::Page;
253    ///
254    /// # fn example(page: &Page) {
255    /// // Filter list items by text
256    /// let item = page.locator("li").filter().has_text("Product");
257    ///
258    /// // Filter by having a child element
259    /// let rows = page.locator("tr").filter().has(page.locator(".active"));
260    /// # }
261    /// ```
262    pub fn filter(&self) -> FilterBuilder<'a> {
263        FilterBuilder::new(self.page, self.selector.clone(), self.options.clone())
264    }
265
266    /// Get an ARIA accessibility snapshot of this element.
267    ///
268    /// The snapshot captures the accessible tree structure as it would be
269    /// exposed to assistive technologies. This is useful for accessibility testing.
270    ///
271    /// # Example
272    ///
273    /// ```no_run
274    /// use viewpoint_core::Page;
275    ///
276    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
277    /// let snapshot = page.locator("form").aria_snapshot().await?;
278    /// println!("{}", snapshot); // YAML-like output
279    /// # Ok(())
280    /// # }
281    /// ```
282    ///
283    /// # Errors
284    ///
285    /// Returns an error if the element is not found or snapshot capture fails.
286    pub async fn aria_snapshot(&self) -> Result<AriaSnapshot, crate::error::LocatorError> {
287        use crate::error::LocatorError;
288
289        if self.page.is_closed() {
290            return Err(LocatorError::PageClosed);
291        }
292
293        // Get the element and evaluate ARIA snapshot
294        let js_selector = self.selector.to_js_expression();
295        let js = format!(
296            r"
297            (function() {{
298                const element = {};
299                if (!element) {{
300                    return {{ error: 'Element not found' }};
301                }}
302                const getSnapshot = {};
303                return getSnapshot(element);
304            }})()
305            ",
306            js_selector,
307            aria::aria_snapshot_js()
308        );
309
310        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
311            .page
312            .connection()
313            .send_command(
314                "Runtime.evaluate",
315                Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
316                    expression: js,
317                    object_group: None,
318                    include_command_line_api: None,
319                    silent: Some(true),
320                    context_id: None,
321                    return_by_value: Some(true),
322                    await_promise: Some(false),
323                }),
324                Some(self.page.session_id()),
325            )
326            .await?;
327
328        if let Some(exception) = result.exception_details {
329            return Err(LocatorError::EvaluationError(exception.text));
330        }
331
332        let value = result.result.value.ok_or_else(|| {
333            LocatorError::EvaluationError("No result from aria snapshot".to_string())
334        })?;
335
336        // Check for error
337        if let Some(error) = value.get("error").and_then(|e| e.as_str()) {
338            return Err(LocatorError::NotFound(error.to_string()));
339        }
340
341        // Parse the snapshot
342        let snapshot: AriaSnapshot = serde_json::from_value(value).map_err(|e| {
343            LocatorError::EvaluationError(format!("Failed to parse aria snapshot: {e}"))
344        })?;
345
346        Ok(snapshot)
347    }
348}
349
350// FilterBuilder and RoleLocatorBuilder are in filter.rs