viewpoint_core/page/evaluate/
mod.rs

1//! JavaScript evaluation functionality.
2//!
3//! This module provides methods for executing JavaScript in the page context.
4
5use std::time::Duration;
6
7use serde::{de::DeserializeOwned, Serialize};
8use tracing::{debug, instrument, trace};
9use viewpoint_cdp::protocol::runtime::{
10    CallFunctionOnParams, EvaluateParams, EvaluateResult, ReleaseObjectParams,
11};
12
13use crate::error::PageError;
14
15use super::Page;
16
17mod wait;
18
19pub use wait::{Polling, WaitForFunctionBuilder};
20
21/// Default evaluation timeout (30 seconds, matching Playwright).
22pub(super) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
23
24/// A handle to a JavaScript object in the page context.
25///
26/// Handles are useful for referencing complex objects that cannot be serialized
27/// (like DOM elements). Remember to dispose of handles when done.
28#[derive(Debug)]
29pub struct JsHandle {
30    /// The object ID from CDP.
31    object_id: String,
32    /// Reference to the page for cleanup.
33    page_session_id: String,
34    /// CDP connection.
35    connection: std::sync::Arc<viewpoint_cdp::CdpConnection>,
36}
37
38impl JsHandle {
39    /// Create a new handle.
40    pub(crate) fn new(
41        object_id: String,
42        page_session_id: String,
43        connection: std::sync::Arc<viewpoint_cdp::CdpConnection>,
44    ) -> Self {
45        Self {
46            object_id,
47            page_session_id,
48            connection,
49        }
50    }
51
52    /// Get the object ID.
53    pub fn object_id(&self) -> &str {
54        &self.object_id
55    }
56
57    /// Get the JSON value of this handle.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the object cannot be serialized to JSON.
62    pub async fn json_value<T: DeserializeOwned>(&self) -> Result<T, PageError> {
63        let params = CallFunctionOnParams {
64            function_declaration: "function() { return this; }".to_string(),
65            object_id: Some(self.object_id.clone()),
66            arguments: None,
67            silent: Some(false),
68            return_by_value: Some(true),
69            generate_preview: None,
70            user_gesture: None,
71            await_promise: Some(true),
72            execution_context_id: None,
73            object_group: None,
74            throw_on_side_effect: None,
75            unique_context_id: None,
76            serialization_options: None,
77        };
78
79        let result: viewpoint_cdp::protocol::runtime::CallFunctionOnResult = self
80            .connection
81            .send_command(
82                "Runtime.callFunctionOn",
83                Some(params),
84                Some(&self.page_session_id),
85            )
86            .await?;
87
88        if let Some(exception) = result.exception_details {
89            return Err(PageError::EvaluationFailed(exception.text));
90        }
91
92        // Handle undefined return values - use null if no value present
93        let value = result.result.value.unwrap_or(serde_json::Value::Null);
94
95        serde_json::from_value(value)
96            .map_err(|e| PageError::EvaluationFailed(format!("Failed to deserialize: {e}")))
97    }
98
99    /// Dispose of this handle, releasing the JavaScript object reference.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if the CDP command fails.
104    pub async fn dispose(self) -> Result<(), PageError> {
105        self.connection
106            .send_command::<_, serde_json::Value>(
107                "Runtime.releaseObject",
108                Some(ReleaseObjectParams {
109                    object_id: self.object_id,
110                }),
111                Some(&self.page_session_id),
112            )
113            .await?;
114        Ok(())
115    }
116}
117
118impl Page {
119    /// Low-level JavaScript evaluation for internal use.
120    ///
121    /// This method is used by locator helpers and other internal code that needs
122    /// direct JavaScript evaluation without the higher-level features of `evaluate()`.
123    ///
124    /// Key differences from `evaluate()`:
125    /// - Does not wrap expressions in function calls
126    /// - Does not await promises (caller must handle)
127    /// - Uses silent mode (no console output)
128    /// - Returns raw `serde_json::Value`
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the page is closed or JavaScript throws an error.
133    pub(crate) async fn evaluate_js_raw(
134        &self,
135        expression: &str,
136    ) -> Result<serde_json::Value, PageError> {
137        if self.closed {
138            return Err(PageError::Closed);
139        }
140
141        let params = EvaluateParams {
142            expression: expression.to_string(),
143            object_group: None,
144            include_command_line_api: None,
145            silent: Some(true),
146            context_id: None,
147            return_by_value: Some(true),
148            await_promise: Some(false),
149        };
150
151        let result: EvaluateResult = self
152            .connection
153            .send_command("Runtime.evaluate", Some(params), Some(&self.session_id))
154            .await?;
155
156        if let Some(exception) = result.exception_details {
157            return Err(PageError::EvaluationFailed(exception.text));
158        }
159
160        result
161            .result
162            .value
163            .ok_or_else(|| PageError::EvaluationFailed("No result value".to_string()))
164    }
165
166    /// Evaluate JavaScript in the page context.
167    ///
168    /// The expression is evaluated and the result is deserialized to the specified type.
169    /// Promises are automatically awaited.
170    ///
171    /// # Type Parameters
172    ///
173    /// * `T` - The return type. Use `serde_json::Value` for dynamic results.
174    ///
175    /// # Example
176    ///
177    /// ```no_run
178    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
179    /// use viewpoint_js::js;
180    ///
181    /// // Simple expression
182    /// let sum: i32 = page.evaluate(js!{ 1 + 2 }).await?;
183    /// assert_eq!(sum, 3);
184    ///
185    /// // Function expression
186    /// let width: i32 = page.evaluate(js!{ () => window.innerWidth }).await?;
187    ///
188    /// // With interpolation (note: returns String so use &)
189    /// let selector = ".my-class";
190    /// let el: serde_json::Value = page.evaluate(&js!{ document.querySelector(#{selector}) }).await?;
191    ///
192    /// // Get document title
193    /// let title: String = page.evaluate(js!{ document.title }).await?;
194    /// # Ok(())
195    /// # }
196    /// ```
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if:
201    /// - The page is closed
202    /// - The JavaScript throws an error
203    /// - The result cannot be deserialized
204    #[instrument(level = "debug", skip(self), fields(expression = %expression))]
205    pub async fn evaluate<T: DeserializeOwned>(&self, expression: &str) -> Result<T, PageError> {
206        self.evaluate_internal(expression, None, DEFAULT_TIMEOUT)
207            .await
208    }
209
210    /// Evaluate JavaScript with an argument.
211    ///
212    /// The argument is serialized to JSON and passed to the function.
213    ///
214    /// # Example
215    ///
216    /// ```no_run
217    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
218    /// // Pass a number
219    /// let doubled: i32 = page.evaluate_with_arg("x => x * 2", 21).await?;
220    /// assert_eq!(doubled, 42);
221    ///
222    /// // Pass an object
223    /// let name: String = page.evaluate_with_arg("obj => obj.name", serde_json::json!({"name": "test"})).await?;
224    /// # Ok(())
225    /// # }
226    /// ```
227    #[instrument(level = "debug", skip(self, arg), fields(expression = %expression))]
228    pub async fn evaluate_with_arg<T: DeserializeOwned, A: Serialize>(
229        &self,
230        expression: &str,
231        arg: A,
232    ) -> Result<T, PageError> {
233        let arg_json = serde_json::to_value(arg).map_err(|e| {
234            PageError::EvaluationFailed(format!("Failed to serialize argument: {e}"))
235        })?;
236
237        self.evaluate_internal(expression, Some(arg_json), DEFAULT_TIMEOUT)
238            .await
239    }
240
241    /// Evaluate JavaScript and return a handle to the result.
242    ///
243    /// Use this when you need to reference the result object later, or when the
244    /// result cannot be serialized (like DOM elements).
245    ///
246    /// # Example
247    ///
248    /// ```no_run
249    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
250    /// // Get a handle to the body element
251    /// let body_handle = page.evaluate_handle("document.body").await?;
252    ///
253    /// // Use the handle in another evaluation
254    /// let tag_name: String = page.evaluate_with_arg("el => el.tagName", body_handle.object_id()).await?;
255    ///
256    /// // Clean up
257    /// body_handle.dispose().await?;
258    /// # Ok(())
259    /// # }
260    /// ```
261    #[instrument(level = "debug", skip(self), fields(expression = %expression))]
262    pub async fn evaluate_handle(&self, expression: &str) -> Result<JsHandle, PageError> {
263        if self.closed {
264            return Err(PageError::Closed);
265        }
266
267        debug!("Evaluating expression for handle");
268
269        // Wrap expression in function if not already
270        let wrapped = wrap_expression(expression);
271
272        let params = EvaluateParams {
273            expression: wrapped,
274            object_group: Some("viewpoint".to_string()),
275            include_command_line_api: None,
276            silent: Some(false),
277            context_id: None,
278            return_by_value: Some(false), // Keep as reference
279            await_promise: Some(true),
280        };
281
282        let result: EvaluateResult = self
283            .connection
284            .send_command("Runtime.evaluate", Some(params), Some(&self.session_id))
285            .await?;
286
287        if let Some(exception) = result.exception_details {
288            return Err(PageError::EvaluationFailed(exception.text));
289        }
290
291        let object_id = result
292            .result
293            .object_id
294            .ok_or_else(|| PageError::EvaluationFailed("Result is not an object".to_string()))?;
295
296        Ok(JsHandle::new(
297            object_id,
298            self.session_id.clone(),
299            self.connection.clone(),
300        ))
301    }
302
303    /// Internal evaluation helper.
304    async fn evaluate_internal<T: DeserializeOwned>(
305        &self,
306        expression: &str,
307        arg: Option<serde_json::Value>,
308        _timeout: Duration,
309    ) -> Result<T, PageError> {
310        if self.closed {
311            return Err(PageError::Closed);
312        }
313
314        trace!(expression = expression, "Evaluating JavaScript");
315
316        // Wrap expression in a function call if needed and handle arguments
317        let final_expression = if let Some(arg_value) = arg {
318            // If we have an argument, we need to use callFunctionOn or wrap the expression
319            let arg_json = serde_json::to_string(&arg_value)
320                .map_err(|e| PageError::EvaluationFailed(format!("Failed to serialize: {e}")))?;
321
322            format!("({expression})({arg_json})")
323        } else {
324            wrap_expression(expression)
325        };
326
327        let params = EvaluateParams {
328            expression: final_expression,
329            object_group: None,
330            include_command_line_api: None,
331            silent: Some(false),
332            context_id: None,
333            return_by_value: Some(true),
334            await_promise: Some(true),
335        };
336
337        let result: EvaluateResult = self
338            .connection
339            .send_command("Runtime.evaluate", Some(params), Some(&self.session_id))
340            .await?;
341
342        if let Some(exception) = result.exception_details {
343            return Err(PageError::EvaluationFailed(exception.text));
344        }
345
346        // Handle undefined return values - use null if no value present
347        // This allows expressions like console.log() which return undefined
348        let value = result.result.value.unwrap_or(serde_json::Value::Null);
349
350        serde_json::from_value(value)
351            .map_err(|e| PageError::EvaluationFailed(format!("Failed to deserialize: {e}")))
352    }
353}
354
355/// Wrap an expression in a function call if it looks like a function.
356pub(super) fn wrap_expression(expression: &str) -> String {
357    let trimmed = expression.trim();
358
359    // If it starts with arrow function syntax or function keyword, wrap it
360    if trimmed.starts_with("()")
361        || trimmed.starts_with("async ()")
362        || trimmed.starts_with("async()")
363        || trimmed.starts_with("function")
364        || trimmed.starts_with("async function")
365    {
366        format!("({trimmed})()")
367    } else {
368        trimmed.to_string()
369    }
370}