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