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}