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    /// Evaluate JavaScript in the page context.
120    ///
121    /// The expression is evaluated and the result is deserialized to the specified type.
122    /// Promises are automatically awaited.
123    ///
124    /// # Type Parameters
125    ///
126    /// * `T` - The return type. Use `serde_json::Value` for dynamic results.
127    ///
128    /// # Example
129    ///
130    /// ```no_run
131    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
132    /// use viewpoint_js::js;
133    ///
134    /// // Simple expression
135    /// let sum: i32 = page.evaluate(js!{ 1 + 2 }).await?;
136    /// assert_eq!(sum, 3);
137    ///
138    /// // Function expression
139    /// let width: i32 = page.evaluate(js!{ () => window.innerWidth }).await?;
140    ///
141    /// // With interpolation (note: returns String so use &)
142    /// let selector = ".my-class";
143    /// let el: serde_json::Value = page.evaluate(&js!{ document.querySelector(#{selector}) }).await?;
144    ///
145    /// // Get document title
146    /// let title: String = page.evaluate(js!{ document.title }).await?;
147    /// # Ok(())
148    /// # }
149    /// ```
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if:
154    /// - The page is closed
155    /// - The JavaScript throws an error
156    /// - The result cannot be deserialized
157    #[instrument(level = "debug", skip(self), fields(expression = %expression))]
158    pub async fn evaluate<T: DeserializeOwned>(&self, expression: &str) -> Result<T, PageError> {
159        self.evaluate_internal(expression, None, DEFAULT_TIMEOUT)
160            .await
161    }
162
163    /// Evaluate JavaScript with an argument.
164    ///
165    /// The argument is serialized to JSON and passed to the function.
166    ///
167    /// # Example
168    ///
169    /// ```no_run
170    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
171    /// // Pass a number
172    /// let doubled: i32 = page.evaluate_with_arg("x => x * 2", 21).await?;
173    /// assert_eq!(doubled, 42);
174    ///
175    /// // Pass an object
176    /// let name: String = page.evaluate_with_arg("obj => obj.name", serde_json::json!({"name": "test"})).await?;
177    /// # Ok(())
178    /// # }
179    /// ```
180    #[instrument(level = "debug", skip(self, arg), fields(expression = %expression))]
181    pub async fn evaluate_with_arg<T: DeserializeOwned, A: Serialize>(
182        &self,
183        expression: &str,
184        arg: A,
185    ) -> Result<T, PageError> {
186        let arg_json = serde_json::to_value(arg).map_err(|e| {
187            PageError::EvaluationFailed(format!("Failed to serialize argument: {e}"))
188        })?;
189
190        self.evaluate_internal(expression, Some(arg_json), DEFAULT_TIMEOUT)
191            .await
192    }
193
194    /// Evaluate JavaScript and return a handle to the result.
195    ///
196    /// Use this when you need to reference the result object later, or when the
197    /// result cannot be serialized (like DOM elements).
198    ///
199    /// # Example
200    ///
201    /// ```no_run
202    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
203    /// // Get a handle to the body element
204    /// let body_handle = page.evaluate_handle("document.body").await?;
205    ///
206    /// // Use the handle in another evaluation
207    /// let tag_name: String = page.evaluate_with_arg("el => el.tagName", body_handle.object_id()).await?;
208    ///
209    /// // Clean up
210    /// body_handle.dispose().await?;
211    /// # Ok(())
212    /// # }
213    /// ```
214    #[instrument(level = "debug", skip(self), fields(expression = %expression))]
215    pub async fn evaluate_handle(&self, expression: &str) -> Result<JsHandle, PageError> {
216        if self.closed {
217            return Err(PageError::Closed);
218        }
219
220        debug!("Evaluating expression for handle");
221
222        // Wrap expression in function if not already
223        let wrapped = wrap_expression(expression);
224
225        let params = EvaluateParams {
226            expression: wrapped,
227            object_group: Some("viewpoint".to_string()),
228            include_command_line_api: None,
229            silent: Some(false),
230            context_id: None,
231            return_by_value: Some(false), // Keep as reference
232            await_promise: Some(true),
233        };
234
235        let result: EvaluateResult = self
236            .connection
237            .send_command("Runtime.evaluate", Some(params), Some(&self.session_id))
238            .await?;
239
240        if let Some(exception) = result.exception_details {
241            return Err(PageError::EvaluationFailed(exception.text));
242        }
243
244        let object_id = result
245            .result
246            .object_id
247            .ok_or_else(|| PageError::EvaluationFailed("Result is not an object".to_string()))?;
248
249        Ok(JsHandle::new(
250            object_id,
251            self.session_id.clone(),
252            self.connection.clone(),
253        ))
254    }
255
256    /// Internal evaluation helper.
257    async fn evaluate_internal<T: DeserializeOwned>(
258        &self,
259        expression: &str,
260        arg: Option<serde_json::Value>,
261        _timeout: Duration,
262    ) -> Result<T, PageError> {
263        if self.closed {
264            return Err(PageError::Closed);
265        }
266
267        trace!(expression = expression, "Evaluating JavaScript");
268
269        // Wrap expression in a function call if needed and handle arguments
270        let final_expression = if let Some(arg_value) = arg {
271            // If we have an argument, we need to use callFunctionOn or wrap the expression
272            let arg_json = serde_json::to_string(&arg_value)
273                .map_err(|e| PageError::EvaluationFailed(format!("Failed to serialize: {e}")))?;
274
275            format!("({expression})({arg_json})")
276        } else {
277            wrap_expression(expression)
278        };
279
280        let params = EvaluateParams {
281            expression: final_expression,
282            object_group: None,
283            include_command_line_api: None,
284            silent: Some(false),
285            context_id: None,
286            return_by_value: Some(true),
287            await_promise: Some(true),
288        };
289
290        let result: EvaluateResult = self
291            .connection
292            .send_command("Runtime.evaluate", Some(params), Some(&self.session_id))
293            .await?;
294
295        if let Some(exception) = result.exception_details {
296            return Err(PageError::EvaluationFailed(exception.text));
297        }
298
299        // Handle undefined return values - use null if no value present
300        // This allows expressions like console.log() which return undefined
301        let value = result.result.value.unwrap_or(serde_json::Value::Null);
302
303        serde_json::from_value(value)
304            .map_err(|e| PageError::EvaluationFailed(format!("Failed to deserialize: {e}")))
305    }
306}
307
308/// Wrap an expression in a function call if it looks like a function.
309pub(super) fn wrap_expression(expression: &str) -> String {
310    let trimmed = expression.trim();
311
312    // If it starts with arrow function syntax or function keyword, wrap it
313    if trimmed.starts_with("()")
314        || trimmed.starts_with("async ()")
315        || trimmed.starts_with("async()")
316        || trimmed.starts_with("function")
317        || trimmed.starts_with("async function")
318    {
319        format!("({trimmed})()")
320    } else {
321        trimmed.to_string()
322    }
323}