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}