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