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//! # Basic Locator Usage
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//!
32//! # Form Filling with Multiple Locators
33//!
34//! Fill forms using multiple locator strategies for resilient tests:
35//!
36//! ```no_run
37//! use viewpoint_core::{Browser, AriaRole};
38//!
39//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
40//! # let browser = Browser::launch().headless(true).launch().await?;
41//! # let context = browser.new_context().await?;
42//! # let page = context.new_page().await?;
43//! // Fill a complete form using different locator strategies
44//! // Use label locators for form fields (most resilient)
45//! page.get_by_label("First Name").fill("John").await?;
46//! page.get_by_label("Last Name").fill("Doe").await?;
47//! page.get_by_label("Email Address").fill("john.doe@example.com").await?;
48//!
49//! // Use placeholder for fields without labels
50//! page.get_by_placeholder("Enter phone number").fill("+1-555-0123").await?;
51//!
52//! // Use role locators for dropdowns and buttons
53//! page.locator("select#country")
54//!     .select_option()
55//!     .label("United States")
56//!     .await?;
57//!
58//! // Use test-id for dynamic/generated elements
59//! page.get_by_test_id("address-line-1").fill("123 Main St").await?;
60//! page.get_by_test_id("address-line-2").fill("Apt 4B").await?;
61//!
62//! // Combine locators for complex forms
63//! let form = page.locator("form#registration");
64//! form.locator("input[name='zipcode']").fill("10001").await?;
65//!
66//! // Check terms checkbox using role
67//! page.get_by_role(AriaRole::Checkbox)
68//!     .with_name("I agree to the terms")
69//!     .build()
70//!     .check()
71//!     .await?;
72//!
73//! // Submit using role locator
74//! page.get_by_role(AriaRole::Button)
75//!     .with_name("Create Account")
76//!     .build()
77//!     .click()
78//!     .await?;
79//! # Ok(())
80//! # }
81//! ```
82//!
83//! # Accessibility Testing at Scale
84//!
85//! Capture and verify ARIA snapshots for accessibility testing across multiple pages:
86//!
87//! ```no_run
88//! use viewpoint_core::{Browser, AriaRole};
89//!
90//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
91//! # let browser = Browser::launch().headless(true).launch().await?;
92//! # let context = browser.new_context().await?;
93//! # let page = context.new_page().await?;
94//! // Capture accessibility snapshot for the entire page
95//! let snapshot = page.aria_snapshot().await?;
96//! println!("Accessibility tree:\n{}", snapshot.to_yaml());
97//!
98//! // Capture snapshot for a specific component
99//! let nav_snapshot = page.locator("nav").aria_snapshot().await?;
100//!
101//! // Verify required ARIA landmarks exist
102//! let main_count = page.get_by_role(AriaRole::Main).build().count().await?;
103//! assert!(main_count >= 1, "Page must have main landmark");
104//!
105//! let nav_count = page.get_by_role(AriaRole::Navigation).build().count().await?;
106//! assert!(nav_count >= 1, "Page must have navigation landmark");
107//!
108//! // Check headings exist (use CSS selector for h1 specifically)
109//! let h1_count = page.locator("h1").count().await?;
110//! assert!(h1_count >= 1, "Page must have at least one h1");
111//!
112//! // Check buttons have accessible names
113//! let buttons = page.get_by_role(AriaRole::Button).build();
114//! let button_count = buttons.count().await?;
115//! for i in 0..button_count {
116//!     let button = buttons.nth(i as i32);
117//!     // Verify button has either aria-label or visible text
118//!     let text = button.text_content().await?;
119//!     let label = button.get_attribute("aria-label").await?;
120//!     assert!(text.is_some() || label.is_some(), "Button {} must have accessible name", i);
121//! }
122//!
123//! // Check images have alt text
124//! let images = page.get_by_role(AriaRole::Img).build();
125//! let img_count = images.count().await?;
126//! for i in 0..img_count {
127//!     let alt = images.nth(i as i32).get_attribute("alt").await?;
128//!     assert!(alt.is_some(), "Image {} must have alt text", i);
129//! }
130//! # Ok(())
131//! # }
132//! ```
133//!
134//! ## Multi-Page Accessibility Auditing
135//!
136//! Run accessibility checks across multiple pages:
137//!
138//! ```no_run
139//! use viewpoint_core::{Browser, AriaRole};
140//!
141//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
142//! let browser = Browser::launch().headless(true).launch().await?;
143//!
144//! // Define pages to audit
145//! let pages_to_audit = vec![
146//!     "https://example.com/",
147//!     "https://example.com/about",
148//!     "https://example.com/contact",
149//! ];
150//!
151//! // Audit each page (can parallelize with tokio::spawn)
152//! for url in pages_to_audit {
153//!     let mut context = browser.new_context().await?;
154//!     let page = context.new_page().await?;
155//!     page.goto(url).goto().await?;
156//!
157//!     // Capture full accessibility snapshot
158//!     let snapshot = page.aria_snapshot().await?;
159//!
160//!     // Check for common accessibility issues:
161//!     // 1. Missing page title
162//!     let title = page.title().await?;
163//!     assert!(!title.is_empty(), "{}: Missing page title", url);
164//!
165//!     // 2. Missing main landmark
166//!     let main_count = page.get_by_role(AriaRole::Main).build().count().await?;
167//!     assert!(main_count >= 1, "{}: Missing main landmark", url);
168//!
169//!     // 3. Check form inputs have labels
170//!     let inputs = page.locator("input:not([type='hidden'])");
171//!     let input_count = inputs.count().await?;
172//!     for i in 0..input_count {
173//!         let input = inputs.nth(i as i32);
174//!         let label = input.get_attribute("aria-label").await?;
175//!         let labelled_by = input.get_attribute("aria-labelledby").await?;
176//!         let id = input.get_attribute("id").await?;
177//!         // Verify input has some form of labelling
178//!         assert!(
179//!             label.is_some() || labelled_by.is_some() || id.is_some(),
180//!             "{}: Input {} missing accessible label", url, i
181//!         );
182//!     }
183//!
184//!     context.close().await?;
185//! }
186//! # Ok(())
187//! # }
188//! ```
189
190mod actions;
191pub mod aria;
192pub(crate) mod aria_js;
193mod aria_role;
194mod aria_snapshot_impl;
195mod builders;
196mod debug;
197mod element;
198mod evaluation;
199mod files;
200mod filter;
201mod helpers;
202mod queries;
203mod select;
204pub(crate) mod selector;
205
206use std::time::Duration;
207
208pub use aria::{AriaCheckedState, AriaSnapshot};
209pub use builders::{
210    CheckBuilder, ClickBuilder, DblclickBuilder, FillBuilder, HoverBuilder, PressBuilder,
211    SelectOptionBuilder, TapBuilder, TypeBuilder,
212};
213pub use element::{BoundingBox, BoxModel, ElementHandle};
214pub use filter::{FilterBuilder, RoleLocatorBuilder};
215pub use selector::{AriaRole, Selector, TextOptions};
216
217use crate::Page;
218
219/// Default timeout for locator operations.
220const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
221
222/// A locator for finding elements on a page.
223///
224/// Locators are lightweight handles that store selection criteria. They don't
225/// query the DOM until an action is performed, enabling auto-waiting.
226#[derive(Debug, Clone)]
227pub struct Locator<'a> {
228    /// Reference to the page.
229    page: &'a Page,
230    /// The selector for finding elements.
231    selector: Selector,
232    /// Locator options.
233    options: LocatorOptions,
234}
235
236/// Options for locator behavior.
237#[derive(Debug, Clone)]
238pub struct LocatorOptions {
239    /// Timeout for operations.
240    pub timeout: Duration,
241}
242
243impl Default for LocatorOptions {
244    fn default() -> Self {
245        Self {
246            timeout: DEFAULT_TIMEOUT,
247        }
248    }
249}
250
251impl<'a> Locator<'a> {
252    /// Create a new locator.
253    pub(crate) fn new(page: &'a Page, selector: Selector) -> Self {
254        Self {
255            page,
256            selector,
257            options: LocatorOptions::default(),
258        }
259    }
260
261    /// Create a new locator with custom options.
262    pub(crate) fn with_options(
263        page: &'a Page,
264        selector: Selector,
265        options: LocatorOptions,
266    ) -> Self {
267        Self {
268            page,
269            selector,
270            options,
271        }
272    }
273
274    /// Get the page this locator belongs to.
275    pub fn page(&self) -> &'a Page {
276        self.page
277    }
278
279    /// Get the selector.
280    pub fn selector(&self) -> &Selector {
281        &self.selector
282    }
283
284    /// Get the options.
285    pub fn options(&self) -> &LocatorOptions {
286        &self.options
287    }
288
289    /// Set a custom timeout for this locator.
290    #[must_use]
291    pub fn timeout(mut self, timeout: Duration) -> Self {
292        self.options.timeout = timeout;
293        self
294    }
295
296    /// Create a child locator that further filters elements.
297    ///
298    /// # Example
299    ///
300    /// ```no_run
301    /// use viewpoint_core::Page;
302    ///
303    /// # fn example(page: &Page) {
304    /// let items = page.locator(".list").locator(".item");
305    /// # }
306    /// ```
307    #[must_use]
308    pub fn locator(&self, selector: impl Into<String>) -> Locator<'a> {
309        Locator {
310            page: self.page,
311            selector: Selector::Chained(
312                Box::new(self.selector.clone()),
313                Box::new(Selector::Css(selector.into())),
314            ),
315            options: self.options.clone(),
316        }
317    }
318
319    /// Select the first matching element.
320    #[must_use]
321    pub fn first(&self) -> Locator<'a> {
322        Locator {
323            page: self.page,
324            selector: Selector::Nth {
325                base: Box::new(self.selector.clone()),
326                index: 0,
327            },
328            options: self.options.clone(),
329        }
330    }
331
332    /// Select the last matching element.
333    #[must_use]
334    pub fn last(&self) -> Locator<'a> {
335        Locator {
336            page: self.page,
337            selector: Selector::Nth {
338                base: Box::new(self.selector.clone()),
339                index: -1,
340            },
341            options: self.options.clone(),
342        }
343    }
344
345    /// Select the nth matching element (0-indexed).
346    #[must_use]
347    pub fn nth(&self, index: i32) -> Locator<'a> {
348        Locator {
349            page: self.page,
350            selector: Selector::Nth {
351                base: Box::new(self.selector.clone()),
352                index,
353            },
354            options: self.options.clone(),
355        }
356    }
357
358    /// Convert the selector to a JavaScript expression for CDP evaluation.
359    pub(crate) fn to_js_selector(&self) -> String {
360        self.selector.to_js_expression()
361    }
362
363    /// Create a locator that matches elements that match both this locator and `other`.
364    ///
365    /// # Example
366    ///
367    /// ```no_run
368    /// use viewpoint_core::{Page, AriaRole};
369    ///
370    /// # fn example(page: &Page) {
371    /// // Find visible buttons with specific text
372    /// let button = page.get_by_role(AriaRole::Button).build()
373    ///     .and(page.get_by_text("Submit"));
374    /// # }
375    /// ```
376    #[must_use]
377    pub fn and(&self, other: Locator<'a>) -> Locator<'a> {
378        Locator {
379            page: self.page,
380            selector: Selector::And(Box::new(self.selector.clone()), Box::new(other.selector)),
381            options: self.options.clone(),
382        }
383    }
384
385    /// Create a locator that matches elements that match either this locator or `other`.
386    ///
387    /// # Example
388    ///
389    /// ```no_run
390    /// use viewpoint_core::{Page, AriaRole};
391    ///
392    /// # fn example(page: &Page) {
393    /// // Find buttons or links
394    /// let clickable = page.get_by_role(AriaRole::Button).build()
395    ///     .or(page.get_by_role(AriaRole::Link).build());
396    /// # }
397    /// ```
398    #[must_use]
399    pub fn or(&self, other: Locator<'a>) -> Locator<'a> {
400        Locator {
401            page: self.page,
402            selector: Selector::Or(Box::new(self.selector.clone()), Box::new(other.selector)),
403            options: self.options.clone(),
404        }
405    }
406
407    /// Create a filter builder to narrow down the elements matched by this locator.
408    ///
409    /// # Example
410    ///
411    /// ```no_run
412    /// use viewpoint_core::Page;
413    ///
414    /// # fn example(page: &Page) {
415    /// // Filter list items by text
416    /// let item = page.locator("li").filter().has_text("Product");
417    ///
418    /// // Filter by having a child element
419    /// let rows = page.locator("tr").filter().has(page.locator(".active"));
420    /// # }
421    /// ```
422    pub fn filter(&self) -> FilterBuilder<'a> {
423        FilterBuilder::new(self.page, self.selector.clone(), self.options.clone())
424    }
425}
426
427// FilterBuilder and RoleLocatorBuilder are in filter.rs
428// aria_snapshot is in aria_snapshot_impl.rs
429
430// FilterBuilder and RoleLocatorBuilder are in filter.rs