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}