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::{
81    BackendNodeId, DescribeNodeParams, DescribeNodeResult, ResolveNodeParams, ResolveNodeResult,
82};
83
84use super::Page;
85use super::locator::ElementHandle;
86use crate::error::{LocatorError, PageError};
87
88/// Parse a ref string to extract the backend node ID.
89///
90/// Refs are formatted as `e{backendNodeId}`, e.g., `e12345`.
91///
92/// # Errors
93///
94/// Returns `LocatorError::EvaluationError` if the ref format is invalid.
95pub fn parse_ref(ref_str: &str) -> Result<BackendNodeId, LocatorError> {
96    if !ref_str.starts_with('e') {
97        return Err(LocatorError::EvaluationError(format!(
98            "Invalid ref format: expected 'e{{backendNodeId}}', got '{ref_str}'"
99        )));
100    }
101
102    ref_str[1..]
103        .parse::<BackendNodeId>()
104        .map_err(|e| LocatorError::EvaluationError(format!("Invalid backend node ID in ref: {e}")))
105}
106
107/// Format a backend node ID as a ref string.
108pub fn format_ref(backend_node_id: BackendNodeId) -> String {
109    format!("e{backend_node_id}")
110}
111
112impl Page {
113    /// Get an element handle from a snapshot ref.
114    ///
115    /// This resolves the ref (format: `e{backendNodeId}`) to an `ElementHandle`
116    /// that can be used for low-level DOM operations.
117    ///
118    /// # Arguments
119    ///
120    /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `e12345`)
121    ///
122    /// # Example
123    ///
124    /// ```no_run
125    /// use viewpoint_core::Page;
126    ///
127    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
128    /// let snapshot = page.aria_snapshot().await?;
129    /// // Assume we found a button with ref "e12345"
130    /// let handle = page.element_from_ref("e12345").await?;
131    /// let text: String = handle.evaluate("this.textContent").await?;
132    /// # Ok(())
133    /// # }
134    /// ```
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if:
139    /// - The page is closed
140    /// - The ref format is invalid
141    /// - The element no longer exists (stale ref)
142    #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id, ref_str = %ref_str))]
143    pub async fn element_from_ref(&self, ref_str: &str) -> Result<ElementHandle<'_>, LocatorError> {
144        if self.is_closed() {
145            return Err(LocatorError::PageClosed);
146        }
147
148        let backend_node_id = parse_ref(ref_str)?;
149        debug!(
150            backend_node_id = backend_node_id,
151            "Resolving ref to element"
152        );
153
154        // Use DOM.resolveNode to get a RemoteObject from the backend node ID
155        let result: ResolveNodeResult = self
156            .connection()
157            .send_command(
158                "DOM.resolveNode",
159                Some(ResolveNodeParams {
160                    node_id: None,
161                    backend_node_id: Some(backend_node_id),
162                    object_group: Some("viewpoint-ref".to_string()),
163                    execution_context_id: None,
164                }),
165                Some(self.session_id()),
166            )
167            .await
168            .map_err(|e| {
169                LocatorError::NotFound(format!(
170                    "Failed to resolve ref '{ref_str}': element may no longer exist. Error: {e}"
171                ))
172            })?;
173
174        let object_id = result.object.object_id.ok_or_else(|| {
175            LocatorError::NotFound(format!(
176                "Failed to get object ID for ref '{ref_str}': element may be detached"
177            ))
178        })?;
179
180        debug!(object_id = %object_id, "Resolved ref to element handle");
181
182        Ok(ElementHandle {
183            object_id,
184            page: self,
185        })
186    }
187
188    /// Create a locator from a snapshot ref.
189    ///
190    /// This creates a `Locator` that targets the element identified by the ref.
191    /// Unlike `element_from_ref`, the locator provides auto-waiting behavior
192    /// and is the preferred way to interact with elements.
193    ///
194    /// # Arguments
195    ///
196    /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `e12345`)
197    ///
198    /// # Example
199    ///
200    /// ```no_run
201    /// use viewpoint_core::Page;
202    ///
203    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
204    /// let snapshot = page.aria_snapshot().await?;
205    /// // Assume we found a button with ref "e12345"
206    /// let locator = page.locator_from_ref("e12345");
207    /// locator.click().await?;
208    /// # Ok(())
209    /// # }
210    /// ```
211    ///
212    /// # Panics
213    ///
214    /// Panics if the ref format is invalid. Use `element_from_ref` if you need
215    /// to handle invalid refs gracefully.
216    pub fn locator_from_ref(&self, ref_str: &str) -> super::Locator<'_> {
217        use super::locator::{Locator, Selector};
218
219        // Parse the ref to validate format
220        let backend_node_id = parse_ref(ref_str)
221            .expect("Invalid ref format. Refs must be in format 'e{backendNodeId}'");
222
223        // Create a locator with a backend node ID selector
224        Locator::new(self, Selector::BackendNodeId(backend_node_id))
225    }
226
227    /// Get the backend node ID for an element from its object ID.
228    ///
229    /// This is a lower-level method used internally during snapshot capture
230    /// to resolve element references.
231    pub(crate) async fn get_backend_node_id(
232        &self,
233        object_id: &str,
234    ) -> Result<BackendNodeId, PageError> {
235        let result: DescribeNodeResult = self
236            .connection()
237            .send_command(
238                "DOM.describeNode",
239                Some(DescribeNodeParams {
240                    node_id: None,
241                    backend_node_id: None,
242                    object_id: Some(object_id.to_string()),
243                    depth: Some(0),
244                    pierce: None,
245                }),
246                Some(self.session_id()),
247            )
248            .await?;
249
250        Ok(result.node.backend_node_id)
251    }
252}