viewpoint_core/page/ref_resolution/
mod.rs

1//! Node reference resolution for ARIA snapshots.
2//!
3//! This module provides functionality to resolve element references from
4//! ARIA snapshots back to DOM elements for interaction.
5//!
6//! # Reference Format
7//!
8//! Element references follow the format `e{backendNodeId}` where `backendNodeId`
9//! is the CDP backend node identifier. This format:
10//!
11//! - Is short and readable
12//! - Uses the `e` prefix to distinguish from frame refs (`frame-0`)
13//! - Maps directly to CDP `backendNodeId` for efficient resolution
14//!
15//! # MCP Server Usage
16//!
17//! This feature is designed for MCP (Model Context Protocol) servers that need to:
18//!
19//! 1. Present an accessibility tree to AI/users
20//! 2. Allow interaction with any element in that tree
21//!
22//! Without refs, users would need to re-query elements by role/name, which is fragile
23//! when multiple elements share the same accessible properties.
24//!
25//! # Example: Click a Button by Ref
26//!
27//! ```no_run
28//! use viewpoint_core::Page;
29//!
30//! # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
31//! // Capture snapshot with refs
32//! let snapshot = page.aria_snapshot().await?;
33//!
34//! // Find a button's ref in the snapshot
35//! if let Some(ref node_ref) = snapshot.node_ref.as_ref() {
36//!     // Resolve ref to element handle (for low-level operations)
37//!     let handle = page.element_from_ref(node_ref).await?;
38//!
39//!     // Or get a locator for auto-waiting behavior (preferred)
40//!     let locator = page.locator_from_ref(node_ref);
41//!     locator.click().await?;
42//! }
43//! # Ok(())
44//! # }
45//! ```
46//!
47//! # Example: Find and Interact with Snapshot Elements
48//!
49//! ```no_run
50//! use viewpoint_core::{Page, AriaSnapshot};
51//!
52//! # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
53//! // Capture snapshot
54//! let snapshot = page.aria_snapshot().await?;
55//!
56//! // Helper to find a button by name
57//! fn find_button_ref(snapshot: &AriaSnapshot, name: &str) -> Option<String> {
58//!     if snapshot.role.as_deref() == Some("button")
59//!         && snapshot.name.as_deref() == Some(name)
60//!     {
61//!         return snapshot.node_ref.clone();
62//!     }
63//!     for child in &snapshot.children {
64//!         if let Some(r) = find_button_ref(child, name) {
65//!             return Some(r);
66//!         }
67//!     }
68//!     None
69//! }
70//!
71//! // Find "Submit" button and click it
72//! if let Some(submit_ref) = find_button_ref(&snapshot, "Submit") {
73//!     page.locator_from_ref(&submit_ref).click().await?;
74//! }
75//! # Ok(())
76//! # }
77//! ```
78
79use tracing::{debug, instrument};
80use viewpoint_cdp::protocol::dom::{BackendNodeId, DescribeNodeParams, DescribeNodeResult, ResolveNodeParams, ResolveNodeResult};
81
82use super::locator::ElementHandle;
83use super::Page;
84use crate::error::{LocatorError, PageError};
85
86/// Parse a ref string to extract the backend node ID.
87///
88/// Refs are formatted as `e{backendNodeId}`, e.g., `e12345`.
89///
90/// # Errors
91///
92/// Returns `LocatorError::EvaluationError` if the ref format is invalid.
93pub fn parse_ref(ref_str: &str) -> Result<BackendNodeId, LocatorError> {
94    if !ref_str.starts_with('e') {
95        return Err(LocatorError::EvaluationError(format!(
96            "Invalid ref format: expected 'e{{backendNodeId}}', got '{ref_str}'"
97        )));
98    }
99
100    ref_str[1..]
101        .parse::<BackendNodeId>()
102        .map_err(|e| LocatorError::EvaluationError(format!("Invalid backend node ID in ref: {e}")))
103}
104
105/// Format a backend node ID as a ref string.
106pub fn format_ref(backend_node_id: BackendNodeId) -> String {
107    format!("e{backend_node_id}")
108}
109
110impl Page {
111    /// Get an element handle from a snapshot ref.
112    ///
113    /// This resolves the ref (format: `e{backendNodeId}`) to an `ElementHandle`
114    /// that can be used for low-level DOM operations.
115    ///
116    /// # Arguments
117    ///
118    /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `e12345`)
119    ///
120    /// # Example
121    ///
122    /// ```no_run
123    /// use viewpoint_core::Page;
124    ///
125    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
126    /// let snapshot = page.aria_snapshot().await?;
127    /// // Assume we found a button with ref "e12345"
128    /// let handle = page.element_from_ref("e12345").await?;
129    /// let text: String = handle.evaluate("this.textContent").await?;
130    /// # Ok(())
131    /// # }
132    /// ```
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if:
137    /// - The page is closed
138    /// - The ref format is invalid
139    /// - The element no longer exists (stale ref)
140    #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id, ref_str = %ref_str))]
141    pub async fn element_from_ref(&self, ref_str: &str) -> Result<ElementHandle<'_>, LocatorError> {
142        if self.is_closed() {
143            return Err(LocatorError::PageClosed);
144        }
145
146        let backend_node_id = parse_ref(ref_str)?;
147        debug!(backend_node_id = backend_node_id, "Resolving ref to element");
148
149        // Use DOM.resolveNode to get a RemoteObject from the backend node ID
150        let result: ResolveNodeResult = self
151            .connection()
152            .send_command(
153                "DOM.resolveNode",
154                Some(ResolveNodeParams {
155                    node_id: None,
156                    backend_node_id: Some(backend_node_id),
157                    object_group: Some("viewpoint-ref".to_string()),
158                    execution_context_id: None,
159                }),
160                Some(self.session_id()),
161            )
162            .await
163            .map_err(|e| {
164                LocatorError::NotFound(format!(
165                    "Failed to resolve ref '{ref_str}': element may no longer exist. Error: {e}"
166                ))
167            })?;
168
169        let object_id = result.object.object_id.ok_or_else(|| {
170            LocatorError::NotFound(format!(
171                "Failed to get object ID for ref '{ref_str}': element may be detached"
172            ))
173        })?;
174
175        debug!(object_id = %object_id, "Resolved ref to element handle");
176
177        Ok(ElementHandle {
178            object_id,
179            page: self,
180        })
181    }
182
183    /// Create a locator from a snapshot ref.
184    ///
185    /// This creates a `Locator` that targets the element identified by the ref.
186    /// Unlike `element_from_ref`, the locator provides auto-waiting behavior
187    /// and is the preferred way to interact with elements.
188    ///
189    /// # Arguments
190    ///
191    /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `e12345`)
192    ///
193    /// # Example
194    ///
195    /// ```no_run
196    /// use viewpoint_core::Page;
197    ///
198    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
199    /// let snapshot = page.aria_snapshot().await?;
200    /// // Assume we found a button with ref "e12345"
201    /// let locator = page.locator_from_ref("e12345");
202    /// locator.click().await?;
203    /// # Ok(())
204    /// # }
205    /// ```
206    ///
207    /// # Panics
208    ///
209    /// Panics if the ref format is invalid. Use `element_from_ref` if you need
210    /// to handle invalid refs gracefully.
211    pub fn locator_from_ref(&self, ref_str: &str) -> super::Locator<'_> {
212        use super::locator::{Locator, Selector};
213        
214        // Parse the ref to validate format
215        let backend_node_id = parse_ref(ref_str)
216            .expect("Invalid ref format. Refs must be in format 'e{backendNodeId}'");
217
218        // Create a locator with a backend node ID selector
219        Locator::new(self, Selector::BackendNodeId(backend_node_id))
220    }
221
222    /// Get the backend node ID for an element from its object ID.
223    ///
224    /// This is a lower-level method used internally during snapshot capture
225    /// to resolve element references.
226    pub(crate) async fn get_backend_node_id(
227        &self,
228        object_id: &str,
229    ) -> Result<BackendNodeId, PageError> {
230        let result: DescribeNodeResult = self
231            .connection()
232            .send_command(
233                "DOM.describeNode",
234                Some(DescribeNodeParams {
235                    node_id: None,
236                    backend_node_id: None,
237                    object_id: Some(object_id.to_string()),
238                    depth: Some(0),
239                    pierce: None,
240                }),
241                Some(self.session_id()),
242            )
243            .await?;
244
245        Ok(result.node.backend_node_id)
246    }
247}