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