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