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