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