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