viewpoint_core/page/locator/evaluation/mod.rs
1//! JavaScript evaluation methods for locators.
2//!
3//! Methods for evaluating JavaScript expressions on elements.
4
5use tracing::{debug, instrument};
6use viewpoint_cdp::protocol::runtime::EvaluateParams;
7
8use super::Locator;
9use super::element::{BoundingBox, ElementHandle};
10use crate::error::LocatorError;
11
12impl<'a> Locator<'a> {
13 /// Evaluate a JavaScript expression with the element as the first argument.
14 ///
15 /// The element is passed as `element` to the expression. The expression
16 /// should be a function body or expression that uses `element`.
17 ///
18 /// # Arguments
19 ///
20 /// * `expression` - JavaScript expression. The element is available as `element`.
21 ///
22 /// # Returns
23 ///
24 /// The result of the JavaScript expression, or an error if evaluation fails.
25 ///
26 /// # Example
27 ///
28 /// ```no_run
29 /// use viewpoint_core::Page;
30 ///
31 /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
32 /// // Get the element's computed style
33 /// let color = page.locator("button")
34 /// .evaluate::<String>("getComputedStyle(element).color")
35 /// .await?;
36 ///
37 /// // Get element dimensions
38 /// let rect = page.locator("button")
39 /// .evaluate::<serde_json::Value>("element.getBoundingClientRect()")
40 /// .await?;
41 ///
42 /// // Modify element state
43 /// page.locator("input")
44 /// .evaluate::<()>("element.value = 'Hello'")
45 /// .await?;
46 /// # Ok(())
47 /// # }
48 /// ```
49 ///
50 /// # Errors
51 ///
52 /// Returns an error if:
53 /// - The element is not found
54 /// - The JavaScript expression fails
55 /// - The result cannot be deserialized to type `T`
56 #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
57 pub async fn evaluate<T: serde::de::DeserializeOwned>(
58 &self,
59 expression: &str,
60 ) -> Result<T, LocatorError> {
61 self.wait_for_actionable().await?;
62
63 debug!(expression, "Evaluating expression on element");
64
65 let js = format!(
66 r"(function() {{
67 const elements = {selector};
68 if (elements.length === 0) return {{ __viewpoint_error: 'Element not found' }};
69
70 const element = elements[0];
71 try {{
72 const result = (function(element) {{ return {expression}; }})(element);
73 return {{ __viewpoint_result: result }};
74 }} catch (e) {{
75 return {{ __viewpoint_error: e.toString() }};
76 }}
77 }})()",
78 selector = self.selector.to_js_expression(),
79 expression = expression
80 );
81
82 let result = self.evaluate_js(&js).await?;
83
84 if let Some(error) = result.get("__viewpoint_error").and_then(|v| v.as_str()) {
85 return Err(LocatorError::EvaluationError(error.to_string()));
86 }
87
88 let value = result
89 .get("__viewpoint_result")
90 .cloned()
91 .unwrap_or(serde_json::Value::Null);
92 serde_json::from_value(value).map_err(|e| {
93 LocatorError::EvaluationError(format!("Failed to deserialize result: {e}"))
94 })
95 }
96
97 /// Evaluate a JavaScript expression on all matching elements.
98 ///
99 /// The elements are passed as `elements` (an array) to the expression.
100 ///
101 /// # Arguments
102 ///
103 /// * `expression` - JavaScript expression. The elements are available as `elements`.
104 ///
105 /// # Returns
106 ///
107 /// The result of the JavaScript expression, or an error if evaluation fails.
108 ///
109 /// # Example
110 ///
111 /// ```no_run
112 /// use viewpoint_core::Page;
113 ///
114 /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
115 /// // Get all element IDs
116 /// let ids = page.locator("button")
117 /// .evaluate_all::<Vec<String>>("elements.map(e => e.id)")
118 /// .await?;
119 ///
120 /// // Count visible elements
121 /// let count = page.locator(".item")
122 /// .evaluate_all::<usize>("elements.filter(e => e.offsetParent !== null).length")
123 /// .await?;
124 ///
125 /// // Get custom data attributes
126 /// let data = page.locator("[data-test]")
127 /// .evaluate_all::<Vec<String>>("elements.map(e => e.dataset.test)")
128 /// .await?;
129 /// # Ok(())
130 /// # }
131 /// ```
132 ///
133 /// # Errors
134 ///
135 /// Returns an error if:
136 /// - The JavaScript expression fails
137 /// - The result cannot be deserialized to type `T`
138 #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
139 pub async fn evaluate_all<T: serde::de::DeserializeOwned>(
140 &self,
141 expression: &str,
142 ) -> Result<T, LocatorError> {
143 debug!(expression, "Evaluating expression on all elements");
144
145 let js = format!(
146 r"(function() {{
147 const elements = Array.from({selector});
148 try {{
149 const result = (function(elements) {{ return {expression}; }})(elements);
150 return {{ __viewpoint_result: result }};
151 }} catch (e) {{
152 return {{ __viewpoint_error: e.toString() }};
153 }}
154 }})()",
155 selector = self.selector.to_js_expression(),
156 expression = expression
157 );
158
159 let result = self.evaluate_js(&js).await?;
160
161 if let Some(error) = result.get("__viewpoint_error").and_then(|v| v.as_str()) {
162 return Err(LocatorError::EvaluationError(error.to_string()));
163 }
164
165 let value = result
166 .get("__viewpoint_result")
167 .cloned()
168 .unwrap_or(serde_json::Value::Null);
169 serde_json::from_value(value).map_err(|e| {
170 LocatorError::EvaluationError(format!("Failed to deserialize result: {e}"))
171 })
172 }
173
174 /// Get a raw element handle for the first matching element.
175 ///
176 /// The returned [`ElementHandle`] provides lower-level access to the DOM element
177 /// and can be used for advanced operations that aren't covered by the Locator API.
178 ///
179 /// **Note:** Unlike locators, element handles are bound to the specific element
180 /// at the time of creation. If the element is removed from the DOM, the handle
181 /// becomes stale.
182 ///
183 /// # Example
184 ///
185 /// ```no_run
186 /// use viewpoint_core::Page;
187 ///
188 /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
189 /// let handle = page.locator("button").element_handle().await?;
190 /// let box_model = handle.box_model().await?;
191 /// println!("Element at: {:?}", box_model);
192 /// # Ok(())
193 /// # }
194 /// ```
195 ///
196 /// # Errors
197 ///
198 /// Returns an error if the element cannot be found.
199 #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
200 pub async fn element_handle(&self) -> Result<ElementHandle<'a>, LocatorError> {
201 self.wait_for_actionable().await?;
202
203 debug!("Getting element handle");
204
205 // Use Runtime.evaluate to get the element object ID
206 let js = format!(
207 r"(function() {{
208 const elements = {selector};
209 if (elements.length === 0) return null;
210 return elements[0];
211 }})()",
212 selector = self.selector.to_js_expression()
213 );
214
215 let params = EvaluateParams {
216 expression: js,
217 object_group: Some("viewpoint-element-handle".to_string()),
218 include_command_line_api: None,
219 silent: Some(true),
220 context_id: None,
221 return_by_value: Some(false),
222 await_promise: Some(false),
223 };
224
225 let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
226 .page
227 .connection()
228 .send_command(
229 "Runtime.evaluate",
230 Some(params),
231 Some(self.page.session_id()),
232 )
233 .await?;
234
235 if let Some(exception) = result.exception_details {
236 return Err(LocatorError::EvaluationError(exception.text));
237 }
238
239 let object_id = result
240 .result
241 .object_id
242 .ok_or_else(|| LocatorError::NotFound(format!("{:?}", self.selector)))?;
243
244 Ok(ElementHandle {
245 object_id,
246 page: self.page,
247 })
248 }
249
250 /// Scroll the element into view if needed.
251 ///
252 /// This scrolls the element's parent container(s) to make the element visible.
253 ///
254 /// # Example
255 ///
256 /// ```no_run
257 /// use viewpoint_core::Page;
258 ///
259 /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
260 /// page.locator(".footer").scroll_into_view_if_needed().await?;
261 /// # Ok(())
262 /// # }
263 /// ```
264 ///
265 /// # Errors
266 ///
267 /// Returns an error if the element cannot be found.
268 #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
269 pub async fn scroll_into_view_if_needed(&self) -> Result<(), LocatorError> {
270 let _info = self.wait_for_actionable().await?;
271
272 debug!("Scrolling element into view");
273
274 let js = format!(
275 r"(function() {{
276 const elements = {selector};
277 if (elements.length === 0) return {{ found: false }};
278
279 const el = elements[0];
280 el.scrollIntoView({{ behavior: 'instant', block: 'center', inline: 'center' }});
281 return {{ found: true }};
282 }})()",
283 selector = self.selector.to_js_expression()
284 );
285
286 let result = self.evaluate_js(&js).await?;
287 let found = result
288 .get("found")
289 .and_then(serde_json::Value::as_bool)
290 .unwrap_or(false);
291 if !found {
292 return Err(LocatorError::NotFound(format!("{:?}", self.selector)));
293 }
294
295 Ok(())
296 }
297
298 /// Get the bounding box of the element.
299 ///
300 /// Returns the element's position and dimensions relative to the viewport.
301 ///
302 /// # Example
303 ///
304 /// ```no_run
305 /// use viewpoint_core::Page;
306 ///
307 /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
308 /// let bbox = page.locator("button").bounding_box().await?;
309 /// if let Some(box_) = bbox {
310 /// println!("Element at ({}, {}), size {}x{}",
311 /// box_.x, box_.y, box_.width, box_.height);
312 /// }
313 /// # Ok(())
314 /// # }
315 /// ```
316 ///
317 /// # Returns
318 ///
319 /// - `Some(BoundingBox)` if the element exists and is visible
320 /// - `None` if the element exists but has no visible bounding box
321 ///
322 /// # Errors
323 ///
324 /// Returns an error if the element cannot be found.
325 pub async fn bounding_box(&self) -> Result<Option<BoundingBox>, LocatorError> {
326 let info = self.query_element_info().await?;
327
328 if !info.found {
329 return Err(LocatorError::NotFound(format!("{:?}", self.selector)));
330 }
331
332 match (info.x, info.y, info.width, info.height) {
333 (Some(x), Some(y), Some(width), Some(height)) if width > 0.0 && height > 0.0 => {
334 Ok(Some(BoundingBox {
335 x,
336 y,
337 width,
338 height,
339 }))
340 }
341 _ => Ok(None),
342 }
343 }
344}