viewpoint_core/page/locator/debug/
mod.rs

1//! Debug and visualization methods for locators.
2//!
3//! Methods for highlighting and debugging element selections.
4
5use std::time::Duration;
6
7use serde::Deserialize;
8use tracing::{debug, instrument};
9use viewpoint_cdp::protocol::dom::{BackendNodeId, ResolveNodeParams, ResolveNodeResult};
10use viewpoint_js::js;
11
12use super::Locator;
13use super::Selector;
14use crate::error::LocatorError;
15
16impl Locator<'_> {
17    /// Highlight the element for debugging purposes.
18    ///
19    /// This visually highlights the element with a magenta outline for 2 seconds,
20    /// making it easy to verify which element is being targeted.
21    ///
22    /// # Example
23    ///
24    /// ```no_run
25    /// use std::time::Duration;
26    /// use viewpoint_core::Page;
27    ///
28    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
29    /// // Highlight for default duration (2 seconds)
30    /// page.locator("button").highlight().await?;
31    ///
32    /// // Highlight for custom duration
33    /// page.locator("button").highlight_for(Duration::from_secs(5)).await?;
34    /// # Ok(())
35    /// # }
36    /// ```
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the element cannot be found or highlighted.
41    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
42    pub async fn highlight(&self) -> Result<(), LocatorError> {
43        self.highlight_for(Duration::from_secs(2)).await
44    }
45
46    /// Highlight the element for a specific duration.
47    ///
48    /// # Arguments
49    ///
50    /// * `duration` - How long to show the highlight.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if the element cannot be found or highlighted.
55    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
56    pub async fn highlight_for(&self, duration: Duration) -> Result<(), LocatorError> {
57        self.wait_for_actionable().await?;
58
59        debug!(?duration, "Highlighting element");
60
61        // Handle Ref selector - lookup in ref map and resolve via CDP
62        if let Selector::Ref(ref_str) = &self.selector {
63            let backend_node_id = self.page.get_backend_node_id_for_ref(ref_str)?;
64            return self
65                .highlight_by_backend_id(backend_node_id, duration)
66                .await;
67        }
68
69        // Handle BackendNodeId selector
70        if let Selector::BackendNodeId(backend_node_id) = &self.selector {
71            return self
72                .highlight_by_backend_id(*backend_node_id, duration)
73                .await;
74        }
75
76        // Add highlight style
77        let selector_expr = self.selector.to_js_expression();
78        let highlight_js = js! {
79            (function() {
80                const elements = @{selector_expr};
81                if (elements.length === 0) return { found: false };
82
83                const el = elements[0];
84                const originalOutline = el.style.outline;
85                const originalOutlineOffset = el.style.outlineOffset;
86                const originalTransition = el.style.transition;
87
88                // Apply highlight with animation
89                el.style.transition = "outline 0.2s ease-in-out";
90                el.style.outline = "3px solid #ff00ff";
91                el.style.outlineOffset = "2px";
92
93                // Store original styles for restoration
94                el.__viewpoint_original_outline = originalOutline;
95                el.__viewpoint_original_outline_offset = originalOutlineOffset;
96                el.__viewpoint_original_transition = originalTransition;
97
98                return { found: true };
99            })()
100        };
101
102        let result = self.evaluate_js(&highlight_js).await?;
103        let found = result
104            .get("found")
105            .and_then(serde_json::Value::as_bool)
106            .unwrap_or(false);
107        if !found {
108            return Err(LocatorError::NotFound(format!("{:?}", self.selector)));
109        }
110
111        // Wait for the duration
112        tokio::time::sleep(duration).await;
113
114        // Remove highlight
115        let cleanup_js = js! {
116            (function() {
117                const elements = @{selector_expr};
118                if (elements.length === 0) return;
119
120                const el = elements[0];
121                el.style.outline = el.__viewpoint_original_outline || "";
122                el.style.outlineOffset = el.__viewpoint_original_outline_offset || "";
123                el.style.transition = el.__viewpoint_original_transition || "";
124
125                delete el.__viewpoint_original_outline;
126                delete el.__viewpoint_original_outline_offset;
127                delete el.__viewpoint_original_transition;
128            })()
129        };
130
131        // Ignore cleanup errors - element may have been removed
132        let _ = self.evaluate_js(&cleanup_js).await;
133
134        Ok(())
135    }
136
137    /// Highlight an element by backend node ID.
138    async fn highlight_by_backend_id(
139        &self,
140        backend_node_id: BackendNodeId,
141        duration: Duration,
142    ) -> Result<(), LocatorError> {
143        // Resolve the backend node ID to a RemoteObject
144        let result: ResolveNodeResult = self
145            .page
146            .connection()
147            .send_command(
148                "DOM.resolveNode",
149                Some(ResolveNodeParams {
150                    node_id: None,
151                    backend_node_id: Some(backend_node_id),
152                    object_group: Some("viewpoint-highlight".to_string()),
153                    execution_context_id: None,
154                }),
155                Some(self.page.session_id()),
156            )
157            .await
158            .map_err(|_| {
159                LocatorError::NotFound(format!(
160                    "Could not resolve backend node ID {backend_node_id}: element may no longer exist"
161                ))
162            })?;
163
164        let object_id = result.object.object_id.ok_or_else(|| {
165            LocatorError::NotFound(format!(
166                "No object ID for backend node ID {backend_node_id}"
167            ))
168        })?;
169
170        // Apply highlight
171        #[derive(Debug, Deserialize)]
172        struct CallResult {
173            result: viewpoint_cdp::protocol::runtime::RemoteObject,
174            #[serde(rename = "exceptionDetails")]
175            exception_details: Option<viewpoint_cdp::protocol::runtime::ExceptionDetails>,
176        }
177
178        let js_highlight = js! {
179            (function() {
180                const el = this;
181                const originalOutline = el.style.outline;
182                const originalOutlineOffset = el.style.outlineOffset;
183                const originalTransition = el.style.transition;
184
185                // Apply highlight with animation
186                el.style.transition = "outline 0.2s ease-in-out";
187                el.style.outline = "3px solid #ff00ff";
188                el.style.outlineOffset = "2px";
189
190                // Store original styles for restoration
191                el.__viewpoint_original_outline = originalOutline;
192                el.__viewpoint_original_outline_offset = originalOutlineOffset;
193                el.__viewpoint_original_transition = originalTransition;
194
195                return { found: true };
196            })
197        };
198        // Strip outer parentheses for CDP functionDeclaration
199        let js_highlight = js_highlight.trim_start_matches('(').trim_end_matches(')');
200
201        let call_result: CallResult = self
202            .page
203            .connection()
204            .send_command(
205                "Runtime.callFunctionOn",
206                Some(serde_json::json!({
207                    "objectId": object_id,
208                    "functionDeclaration": js_highlight,
209                    "returnByValue": true
210                })),
211                Some(self.page.session_id()),
212            )
213            .await?;
214
215        if let Some(exception) = call_result.exception_details {
216            let _ = self
217                .page
218                .connection()
219                .send_command::<_, serde_json::Value>(
220                    "Runtime.releaseObject",
221                    Some(serde_json::json!({ "objectId": object_id })),
222                    Some(self.page.session_id()),
223                )
224                .await;
225            return Err(LocatorError::EvaluationError(exception.text));
226        }
227
228        // Wait for the duration
229        tokio::time::sleep(duration).await;
230
231        // Remove highlight
232        let js_remove_highlight = js! {
233            (function() {
234                const el = this;
235                el.style.outline = el.__viewpoint_original_outline || "";
236                el.style.outlineOffset = el.__viewpoint_original_outline_offset || "";
237                el.style.transition = el.__viewpoint_original_transition || "";
238
239                delete el.__viewpoint_original_outline;
240                delete el.__viewpoint_original_outline_offset;
241                delete el.__viewpoint_original_transition;
242            })
243        };
244        // Strip outer parentheses for CDP functionDeclaration
245        let js_remove_highlight = js_remove_highlight
246            .trim_start_matches('(')
247            .trim_end_matches(')');
248
249        let _ = self
250            .page
251            .connection()
252            .send_command::<_, CallResult>(
253                "Runtime.callFunctionOn",
254                Some(serde_json::json!({
255                    "objectId": object_id,
256                    "functionDeclaration": js_remove_highlight,
257                    "returnByValue": true
258                })),
259                Some(self.page.session_id()),
260            )
261            .await;
262
263        // Release the object
264        let _ = self
265            .page
266            .connection()
267            .send_command::<_, serde_json::Value>(
268                "Runtime.releaseObject",
269                Some(serde_json::json!({ "objectId": object_id })),
270                Some(self.page.session_id()),
271            )
272            .await;
273
274        Ok(())
275    }
276}